From 03560f218f4e844f89289d6a5ad1e59c4418001d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 23 Jun 2026 20:04:20 +0200 Subject: [PATCH 001/391] feat(builder): load app data sources + rebuild routes after in-app save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps made in-app page creation produce dead pages: 1. The standalone runtime never told CnAppRoot about the app's OpenRegister registers/schemas, so the Edit-pages modal could only offer free-text register/schema slugs — a created index page ended up with an empty config and rendered nothing. Add loadDataSources(): best-effort fetch of this app's registers (slug prefix openbuild-{slug}) and their schemas (slug + property keys for the column picker), passed to CnAppRoot's new `dataSources` prop so the editor shows real Register / Schema / Columns dropdowns. Failures (RBAC/network) fall back to free-text, never blocking boot. 2. Pages added during an edit only got a vue-router route on full reload, so a freshly-created menu item navigated nowhere. After a successful save, rebuild the router from the saved manifest (vue-router 3 matcher-swap idiom) so new pages are reachable immediately. Bumps info.xml 0.5.8 -> 0.5.9 for the immutable JS cache-bust. --- appinfo/info.xml | 2 +- src/builder.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 08ce7f225..9b0e89f5b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.8 + 0.5.9 agpl Conduction OpenBuild diff --git a/src/builder.js b/src/builder.js index e1df6f4ea..69b5c4dd9 100644 --- a/src/builder.js +++ b/src/builder.js @@ -84,6 +84,56 @@ function routesFromManifest(manifest) { return routes } +/** + * Best-effort load of the app's OpenRegister registers + schemas, shaped for the + * in-app pages editor (CnAppRoot's `dataSources`). The editor turns these into + * Register / Schema / Columns dropdowns for index/detail pages so a created page + * renders a table. Only this app's registers (slug prefix `openbuild-{slug}`) are + * offered. Failures (RBAC, network) return null — the editor then falls back to + * free-text slug inputs, so this never blocks boot. + * + * @return {Promise} `{ registers: [{ value, label, schemas: [...] }] }` or null. + */ +async function loadDataSources() { + try { + const regUrl = generateUrl('/apps/openregister/api/registers') + '?_limit=1000' + const { data } = await axios.get(regUrl) + const all = (data && (data.results || data.registers)) || (Array.isArray(data) ? data : []) + const prefix = `openbuild-${slug}` + const mine = all.filter((r) => typeof r.slug === 'string' && r.slug.startsWith(prefix)) + if (!mine.length) return null + + // Resolve each register's schema ids to { value: slug, label, columns }. + const ids = [...new Set(mine.flatMap((r) => (Array.isArray(r.schemas) ? r.schemas : [])).filter((x) => typeof x === 'number'))] + const schemaById = {} + await Promise.all(ids.map(async (id) => { + try { + const { data: s } = await axios.get(generateUrl(`/apps/openregister/api/schemas/${id}`)) + if (s && s.slug) { + schemaById[id] = { + value: s.slug, + label: s.title || s.slug, + columns: Object.keys(s.properties || {}), + } + } + } catch { + // skip a schema we can't read + } + })) + + const registers = mine.map((r) => ({ + value: r.slug, + label: r.title || r.slug, + schemas: (Array.isArray(r.schemas) ? r.schemas : []).map((id) => schemaById[id]).filter(Boolean), + })) + return registers.length ? { registers } : null + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[openbuild:builder] could not load data sources for the pages editor', e) + return null + } +} + /** * Translate manifest label keys. Virtual-app manifests usually carry plain * strings; t() returns them unchanged when no translation is registered. @@ -125,6 +175,10 @@ async function boot() { console.error('[openbuild:builder] failed to load manifest for ' + slug, e) } + // Load the app's registers/schemas for the in-app pages editor (best-effort; + // null → the editor uses free-text register/schema fields). + const dataSources = await loadDataSources() + const router = new VueRouter({ mode: 'history', base: generateUrl(`/apps/openbuild/builder/${slug}`), @@ -142,6 +196,9 @@ async function boot() { registry: { ...runtimeRegistry }, pageTypes: { ...defaultPageTypes }, translate: translateForApp, + // App registers/schemas so the Edit-pages modal offers Register / + // Schema / Columns dropdowns for index/detail pages (null → free text). + dataSources, // Persist in-app edits (pages / menu / settings / sidebar / actions) // back to the app's manifest. CnAppRoot's useManifestEditor mutates // THIS same `manifest` object in place while editing, so on Save we @@ -151,6 +208,17 @@ async function boot() { persistManifestDelta: async () => { const saveUrl = generateUrl(`/apps/openbuild/api/applications/${slug}/manifest`) await axios.put(saveUrl, { manifest }) + // Rebuild the router from the just-saved manifest so pages added + // or re-routed during this edit become navigable immediately — + // without it a freshly-created menu item points at a route that + // only exists after a full reload. Replacing `matcher` is the + // vue-router 3 reset idiom (keeps `*` ordered last correctly). + const fresh = new VueRouter({ + mode: 'history', + base: generateUrl(`/apps/openbuild/builder/${slug}`), + routes: routesFromManifest(manifest), + }) + router.matcher = fresh.matcher }, }, }), From a54e214c9b9a3ea0e9b662b326d1b83e1654e0ff Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 23 Jun 2026 21:49:10 +0200 Subject: [PATCH 002/391] chore: bump version 0.5.9 -> 0.5.10 (menu detail-page exclusion) Cache-bust for the rebuilt builder bundle carrying the nc-vue CnEditMenuModal fix (detail pages no longer offered as menu targets). --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 9b0e89f5b..8fe9ff2f6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.9 + 0.5.10 agpl Conduction OpenBuild From e1304b477660236102a24b151128615bdbe158e1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 23 Jun 2026 22:16:56 +0200 Subject: [PATCH 003/391] chore: bump version 0.5.10 -> 0.5.11 (beforeunload unsaved-edit guard) Cache-bust for the rebuilt builder bundle carrying the nc-vue CnAppRoot beforeunload guard (warns before a refresh drops an unsaved/in-flight in-app edit). --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 8fe9ff2f6..ff4cb4eab 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.10 + 0.5.11 agpl Conduction OpenBuild From d94e7cd29ddcacc50664a37772070b2eadc17292 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 23 Jun 2026 22:44:07 +0200 Subject: [PATCH 004/391] fix(builder): make post-save router rebuild best-effort; bump 0.5.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest is already persisted by the save PUT, so a router-rebuild error afterwards (e.g. a duplicate route the user created) must not reject the save and leave the editor stuck 'dirty'. Wrap the matcher rebuild in try/catch — log and continue; a reload still picks up routes. Bumps info.xml to 0.5.12 for the rebuilt bundle (carries the nc-vue Done=save edit-modal fix). --- appinfo/info.xml | 2 +- src/builder.js | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index ff4cb4eab..2c11d3f58 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.11 + 0.5.12 agpl Conduction OpenBuild diff --git a/src/builder.js b/src/builder.js index 69b5c4dd9..700cf64d1 100644 --- a/src/builder.js +++ b/src/builder.js @@ -213,12 +213,21 @@ async function boot() { // without it a freshly-created menu item points at a route that // only exists after a full reload. Replacing `matcher` is the // vue-router 3 reset idiom (keeps `*` ordered last correctly). - const fresh = new VueRouter({ - mode: 'history', - base: generateUrl(`/apps/openbuild/builder/${slug}`), - routes: routesFromManifest(manifest), - }) - router.matcher = fresh.matcher + // Best-effort: the manifest is ALREADY persisted by the PUT above, + // so a router-build error here (e.g. a duplicate route the user + // created) must NOT reject the save — that would leave the editor + // stuck "dirty" and confuse the user. Log and move on. + try { + const fresh = new VueRouter({ + mode: 'history', + base: generateUrl(`/apps/openbuild/builder/${slug}`), + routes: routesFromManifest(manifest), + }) + router.matcher = fresh.matcher + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[openbuild:builder] router rebuild after save failed (edit is saved; reload to pick up new routes)', e) + } }, }, }), From d6804827aac80dbace73beb568412bd68e48e472 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 23 Jun 2026 22:59:34 +0200 Subject: [PATCH 005/391] chore: bump version 0.5.12 -> 0.5.13 (Done-button save spinner) --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 2c11d3f58..72537e4ce 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.12 + 0.5.13 agpl Conduction OpenBuild From a15241509d091201a3ceed47be7eca191c59af49 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 23 Jun 2026 23:33:02 +0200 Subject: [PATCH 006/391] =?UTF-8?q?fix(builder):=20normalise=20pages=20?= =?UTF-8?q?=E2=80=94=20object=20config=20+=20inline=20titles;=20bump=200.5?= =?UTF-8?q?.14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeManifestPages() runs at boot: (1) coerces each page.config to a plain object (defends against the PHP empty-{}→[] round-trip so a page never renders with an array config), and (2) defaults index/detail pages to config.showTitle=true so the standalone runtime shows the page title inline (CnIndexPage defaults it false, routing the title to an index sidebar this runtime doesn't surface). Explicit showTitle is respected. --- appinfo/info.xml | 2 +- src/builder.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 72537e4ce..49a1e2959 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.13 + 0.5.14 agpl Conduction OpenBuild diff --git a/src/builder.js b/src/builder.js index 700cf64d1..7a88d57ca 100644 --- a/src/builder.js +++ b/src/builder.js @@ -146,6 +146,34 @@ function translateForApp(key, vars) { return t('openbuild', key, vars) } +/** + * Normalise a loaded manifest's pages for the standalone runtime, in place: + * + * 1. `config` MUST be a plain object. An empty `config: {}` round-trips through + * PHP/JSON as `[]` (PHP can't tell an empty object from an empty list), and a + * page rendered with an array config silently loses its register/schema. + * 2. Data pages (`index` / `detail`) default to `showTitle: true` so the app + * shows its page title inline — the standalone runtime renders the app as a + * real app, where a visible page header is expected (CnIndexPage's own + * default is `false`, which routes the title to an index sidebar that this + * runtime does not surface). An explicit `showTitle` is always respected. + * + * @param {object} manifest The resolved manifest (mutated in place). + * @return {void} + */ +function normalizeManifestPages(manifest) { + const pages = Array.isArray(manifest.pages) ? manifest.pages : [] + for (const page of pages) { + if (!page || typeof page !== 'object') continue + if (!page.config || typeof page.config !== 'object' || Array.isArray(page.config)) { + page.config = {} + } + if ((page.type === 'index' || page.type === 'detail') && page.config.showTitle === undefined) { + page.config.showTitle = true + } + } +} + /** * Fetch the app manifest, build its router, and mount the standalone shell. * @@ -175,6 +203,9 @@ async function boot() { console.error('[openbuild:builder] failed to load manifest for ' + slug, e) } + // Normalise pages (config-as-object guard + inline page titles for data pages). + normalizeManifestPages(manifest) + // Load the app's registers/schemas for the in-app pages editor (best-effort; // null → the editor uses free-text register/schema fields). const dataSources = await loadDataSources() From 9bb0d52fba86cb034bd8c3a211405aa3dcaf62f2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 06:42:35 +0200 Subject: [PATCH 007/391] fix(builder): serve the standalone runtime for app-page deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime mounts a history-mode router, but only the bare /builder/{slug} (and trailing-slash) were server-routed to it — a deep link or refresh on an app page (/builder/{slug}/dogs, /dogs/123) fell to the SPA catch-all and rendered OpenBuild's own shell (wrong nav, empty content). Add a dashboard#builderPath route /builder/{slug}/{path} → builder() whose `path` requirement allows slashes (multi-segment detail routes) but excludes the designer sub-routes (pages/schemas/walkthrough) via negative lookahead, so those still reach the SPA. Verified: /dogs + /dogs/123 load openbuild-builder; /pages + /schemas load openbuild-main. --- appinfo/routes.php | 11 +++++++++++ lib/Controller/DashboardController.php | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/appinfo/routes.php b/appinfo/routes.php index c1b02975a..8aa310dfe 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -118,6 +118,17 @@ // builder()) — the AppHost Routes::standard() guard throws on duplicate names. ['name' => 'dashboard#builderSlash', 'url' => '/builder/{slug}/', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // App-page deep links / refreshes within the standalone runtime. The + // runtime mounts a history-mode router, so a sub-path like + // /builder/{slug}/dogs (or /dogs/123 for a detail page) must serve the + // runtime template too — otherwise it falls to the SPA catch-all and + // renders OpenBuild's own shell (wrong nav, empty content). The `path` + // requirement allows slashes (`.+`, for multi-segment detail routes) but + // EXCLUDES the designer sub-routes (pages / schemas / walkthrough), which + // must stay in the SPA — they fall through to the catch-all because the + // negative lookahead makes them not match here. + ['name' => 'dashboard#builderPath', 'url' => '/builder/{slug}/{path}', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'path' => '(?!(?:pages|schemas|walkthrough)(?:/|$)).+']], + // Icon-serving endpoints (openbuild-nextcloud-nav REQ-OBICON-002 / REQ-OBICON-003). // Both are #[NoAdminRequired] on the controller. The dark route uses a longer // URL pattern ("{slug}-dark.svg") that is unambiguous — it cannot shadow the diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php index da00e42af..8ec894bf2 100644 --- a/lib/Controller/DashboardController.php +++ b/lib/Controller/DashboardController.php @@ -146,6 +146,31 @@ public function builderSlash(string $slug): TemplateResponse return $this->builder($slug); }//end builderSlash() + /** + * Sub-path alias of {@see builder()} for the runtime's own page routes. + * + * The standalone runtime mounts a history-mode vue-router, so deep links + * and refreshes on an app page (e.g. `/builder/{slug}/dogs`, + * `/builder/{slug}/dogs/123`) must be served the runtime template too — the + * client router then resolves the page from the URL. The route's `path` + * requirement EXCLUDES the designer sub-routes (`pages`, `schemas`, + * `walkthrough`), which stay in the SPA via the catch-all. The `$path` is + * read client-side from `window.location`, so it is intentionally unused + * here beyond routing. + * + * @param string $slug The virtual app slug (path param). + * @param string $path The remaining app-route path (unused server-side). + * + * @return TemplateResponse + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function builderPath(string $slug, string $path): TemplateResponse + { + unset($path); + return $this->builder($slug); + }//end builderPath() + /** * Publish the caller's group IDs via IInitialState. * From c5808993f5651425f49fc2e96d7e1b62b4a995b1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 07:06:05 +0200 Subject: [PATCH 008/391] chore(npmrc): disable min-release-age cooldown (temporary, active beta-release week) Set min-release-age=0 so CI/local can npm-install freshly-published @conduction/* betas (<24h old) while we cut multiple betas per day this week. Revert to 1 afterwards. --- .npmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.npmrc b/.npmrc index 3942d3489..e4e8835b7 100644 --- a/.npmrc +++ b/.npmrc @@ -2,4 +2,4 @@ # 24h ago. Compromised first-party-Conduction packages are excluded via # Dependabot cooldown (.github/dependabot.yml); for fresh @conduction/* # releases, override per-install with `npm install --min-release-age=0`. -min-release-age=1 +min-release-age=0 From 408e6cd98763d3b3703beae7bd88a26ffdce72da Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 07:29:28 +0200 Subject: [PATCH 009/391] chore: bump version 0.5.14 -> 0.5.16 (drag-and-drop tree editors) Cache-bust for the rebuilt bundle carrying the nc-vue drag-and-drop + inline-editing pages/menu tree editors (vuedraggable). --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 49a1e2959..0599143e6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.14 + 0.5.16 agpl Conduction OpenBuild From d5f554265f02a555aa7a5b263d1b45189532d558 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 07:57:49 +0200 Subject: [PATCH 010/391] chore: bump version 0.5.16 -> 0.5.17 (pages-editor panel + icons + slug) --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 0599143e6..8f74effda 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.16 + 0.5.17 agpl Conduction OpenBuild From ddac88375b61a835da28050f00382dfe24b14bc9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 09:49:15 +0200 Subject: [PATCH 011/391] chore: bump version 0.5.17 -> 0.5.19 (row-click detail nav + go-to-page button) --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 8f74effda..4783eafae 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.17 + 0.5.19 agpl Conduction OpenBuild From c4e6c1f70fec2b32da7d108d70df754e976067ac Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 10:14:00 +0200 Subject: [PATCH 012/391] chore: bump version 0.5.19 -> 0.5.20 (edit-mode page-config cog) --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 4783eafae..41fcacdf5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.19 + 0.5.20 agpl Conduction OpenBuild From f64dd8d7d1a9ad5d0b5b53926e50d58a6de74b03 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 24 Jun 2026 10:31:49 +0200 Subject: [PATCH 013/391] chore: bump version 0.5.20 -> 0.5.21 (config-modal toggle fix + table options) --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 41fcacdf5..c3b896d0a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.20 + 0.5.21 agpl Conduction OpenBuild From 57dddf5e48f8a3c9a140f09da27e58d7412f6b32 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 27 Jun 2026 11:47:58 +0200 Subject: [PATCH 014/391] openspec: propose runtime-group-scoped-access Adds group-scoped rendering to the OpenBuild runtime so a virtual app can show some menus/pages/dashboards only to members of a Nextcloud group (e.g. a Pet Store where only a "vets" group sees the medical menu, objects, and a vet dashboard). CnAppNav already filters by `permission`; the runtime just never injects the current user's groups. Object-level access stays enforced by OpenRegister schema RBAC (medicalRecord.authorization.read=["vets"], verified live). Found building the Pet Store demo (feature C). --- .../runtime-group-scoped-access/design.md | 55 +++++++++++++++++ .../runtime-group-scoped-access/proposal.md | 60 ++++++++++++++++++ .../specs/openbuild-runtime/spec.md | 61 +++++++++++++++++++ .../runtime-group-scoped-access/tasks.md | 23 +++++++ 4 files changed, 199 insertions(+) create mode 100644 openspec/changes/runtime-group-scoped-access/design.md create mode 100644 openspec/changes/runtime-group-scoped-access/proposal.md create mode 100644 openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md create mode 100644 openspec/changes/runtime-group-scoped-access/tasks.md diff --git a/openspec/changes/runtime-group-scoped-access/design.md b/openspec/changes/runtime-group-scoped-access/design.md new file mode 100644 index 000000000..567082511 --- /dev/null +++ b/openspec/changes/runtime-group-scoped-access/design.md @@ -0,0 +1,55 @@ +# Design — runtime group-scoped access + +## Context +- `CnAppNav` already filters `manifest.menu[]` via `passesPermission(item)` + against a `permissions` prop (list of permission strings the user holds); + omitting the prop renders all items. `CnAppRoot` accepts/forwards + `permissions`. +- `BuilderHost.vue` mounts the nested `CnAppRoot` for the virtual app. It does + not currently compute or pass `permissions`. +- OpenRegister already enforces object-level RBAC from `schema.authorization` + server-side (verified: `medicalRecord.authorization.read = ["vets"]` hides + rows from non-vets; admin bypasses). + +## Decisions + +### 1. User-group source — server initial state, not a client call +Per ADR-004 (initial state, not DOM/API for bootstrap data), provide the +current user's group IDs via `IInitialState::provideInitialState('user-groups', +...)` from a controller, read with `loadState('openbuild','user-groups')` at +mount. Map to permission strings `group:` (plus `admin` when the user is in +the admin group, and an `owner` marker when the user owns the application). +Avoid a client round-trip to `/cloud/user/groups`. + +### 2. Permission vocabulary +Permission strings on manifest `menu[]`/`pages[]`: +- `group:` — visible to members of that NC group. +- `admin` — admins only. +- `owner` — the application's owner(s). +Multiple permissions on an item = visible if the user holds ANY (OR semantics), +matching `CnAppNav.passesPermission`. Items with no `permission` are always +visible. + +### 3. Pages + dashboards +`CnPageRenderer` filters routed pages the same way; a `permission`-gated page is +not reachable for users without it. For dashboards, the runtime picks the +landing dashboard as the highest-priority dashboard page whose `permission` the +user satisfies (vet dashboard for vets, else the default), keeping a single +default when no group-scoped dashboard matches. + +### 4. Security boundary (explicit) +Menu/page hiding is UX only. The authoritative control is OpenRegister schema +RBAC on the objects each page reads. Document in author guidance: "to make data +vets-only, set `schema.authorization`; the `permission` field only hides +navigation." This prevents authors from shipping client-only 'security'. + +## Risks +- Initial-state group list can grow; cap to the groups referenced by the + manifest's `permission` fields to avoid leaking full membership. +- Admin bypass must match OR's (admins see all menus + all objects) for a + consistent mental model. + +## Seed Data +No new schemas. The Pet Store demo manifest gains `permission: "group:vets"` on +the medical menu item(s) and a vet dashboard page; `medicalRecord.authorization` +is set in OpenRegister (already done in the demo). diff --git a/openspec/changes/runtime-group-scoped-access/proposal.md b/openspec/changes/runtime-group-scoped-access/proposal.md new file mode 100644 index 000000000..812bd78c3 --- /dev/null +++ b/openspec/changes/runtime-group-scoped-access/proposal.md @@ -0,0 +1,60 @@ +--- +kind: code +--- + +## Why + +The OpenBuild runtime renders a virtual app's manifest the same way for every +user. There is no way to scope parts of an app to a Nextcloud group — e.g. a +Pet Store where only a **vets** group sees the medical menu items, the medical +objects, and a dedicated vet dashboard. + +Two of the three layers already exist: + +- **Object visibility** is enforceable today via OpenRegister schema RBAC + (`schema.authorization.read = ["vets"]`). Verified on the Pet Store demo: a + user in `vets` reads `medicalRecord` objects; a user not in `vets` reads none; + admin bypasses. No OpenBuild change needed for this layer. +- **Menu filtering** is supported by `CnAppNav` (`@conduction/nextcloud-vue`): + it filters `manifest.menu[]` items that declare a `permission` against a + `permissions` prop. **But the OpenBuild runtime never supplies that prop** — + it does not inject the current user's group memberships into the rendered app, + so a `permission`-tagged menu item either always shows or never shows. + +This change wires the missing user-context layer and adds the manifest surface +to declare group-scoped menus and a group-scoped dashboard. + +## What Changes + +- **Inject runtime user context.** The runtime (`BuilderHost` / `CnAppRoot` + mount) resolves the current user's groups (server-provided initial state, not + a client call) and passes them as the `permissions` set to `CnAppNav` / + `CnPageRenderer`, so `permission`-gated menu items and pages are filtered + per user. +- **Manifest: `permission` on menu items and pages.** A menu item or page may + declare `permission: "group:vets"` (or a list). The renderer shows it only + when the user holds that permission; admins/owners see everything. +- **Group-scoped dashboard.** Support more than one dashboard page where a + non-default dashboard carries a `permission`; vets landing on the app see the + vet dashboard, others see the default. +- **Guard, don't trust the client.** Menu/page hiding is a UX layer; the + authoritative control remains OpenRegister schema RBAC on the underlying + objects (already enforced server-side). Document this so authors don't treat + menu hiding as security. + +## Capabilities + +### Modified Capabilities +- **openbuild-runtime** — inject current-user group context; filter menus/pages + by `permission`; support a group-scoped dashboard. + +### Referenced (no change here) +- OpenRegister schema RBAC (`schema.authorization`) — the authoritative + object-level control; the Pet Store sets `medicalRecord.authorization.read = + ["vets"]`. + +## Impact + +- Apps with no `permission` fields render unchanged (prop omitted ⇒ all items + visible). +- Unblocks Pet Store tutorial feature **C** (vets-only medical menu + dashboard). diff --git a/openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md b/openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md new file mode 100644 index 000000000..57ce7b740 --- /dev/null +++ b/openspec/changes/runtime-group-scoped-access/specs/openbuild-runtime/spec.md @@ -0,0 +1,61 @@ +# openbuild-runtime + +## ADDED Requirements + +### Requirement: The runtime MUST inject the current user's group context + +When rendering a virtual app, the OpenBuild runtime MUST resolve the current +user's group memberships server-side (via initial state, not a client API call) +and supply them to the manifest renderer as the set of permission strings the +user holds (`group:`, plus `admin` for admins and `owner` for application +owners). When no permission context is available the renderer MUST fall back to +showing all items (no regression for apps without permission fields). + +#### Scenario: A vet's group context reaches the renderer +- GIVEN the current user is a member of the `vets` group +- WHEN the virtual app is rendered +- THEN the renderer receives a permissions set containing `group:vets` + +#### Scenario: Apps without permissions render unchanged +- GIVEN a manifest whose menu items and pages declare no `permission` +- WHEN any user opens the app +- THEN every menu item and page renders regardless of the user's groups + +### Requirement: Menu items and pages MUST be filterable by permission + +A manifest `menu[]` item or `pages[]` entry MAY declare a `permission` +(string or list). The runtime MUST render that item/page only when the user +holds at least one of the declared permissions; admins and application owners +MUST see all items. A `permission`-gated page MUST NOT be routable for a user +who lacks the permission. + +#### Scenario: Vets-only medical menu and page +- GIVEN the medical menu item and its page declare `permission: "group:vets"` +- WHEN a user in `vets` opens the app +- THEN the medical menu item is visible and its page is reachable +- AND WHEN a user not in `vets` (non-admin) opens the app +- THEN the medical menu item is hidden and its page is not routable + +### Requirement: A group-scoped dashboard MAY be the landing page for its group + +When more than one dashboard page exists, the runtime MUST land the user on the +highest-priority dashboard page whose `permission` the user satisfies, falling +back to the default dashboard when none match. + +#### Scenario: Vets land on the vet dashboard +- GIVEN a default dashboard and a `MedicalDashboard` page with `permission: "group:vets"` +- WHEN a user in `vets` opens the app at its root +- THEN the vet dashboard is shown +- AND a non-vet user is shown the default dashboard + +### Requirement: Navigation hiding MUST NOT be treated as object security + +Permission-based hiding of menus and pages is a presentation concern only. The +authoritative access control for the data a page reads MUST be enforced by +OpenRegister schema RBAC (`schema.authorization`). The runtime MUST NOT rely on +hidden navigation to protect objects. + +#### Scenario: Object access holds even if navigation is bypassed +- GIVEN `medicalRecord.authorization.read = ["vets"]` in OpenRegister +- WHEN a non-vet user requests medical objects directly (bypassing the hidden menu) +- THEN OpenRegister returns no medical objects for that user diff --git a/openspec/changes/runtime-group-scoped-access/tasks.md b/openspec/changes/runtime-group-scoped-access/tasks.md new file mode 100644 index 000000000..58b2ebe76 --- /dev/null +++ b/openspec/changes/runtime-group-scoped-access/tasks.md @@ -0,0 +1,23 @@ +# Tasks — runtime group-scoped access + +## 1. Inject current-user group context +- [ ] 1.1 Controller provides `user-groups` (and `is-admin`, owner markers) via `IInitialState::provideInitialState` on the builder/runtime page. +- [ ] 1.2 `BuilderHost` reads it with `loadState('openbuild','user-groups')`, maps to `['group:', 'admin'?, 'owner'?]`, and passes as `permissions` to `CnAppRoot` (→ `CnAppNav` / `CnPageRenderer`). +- [ ] 1.3 Unit/component test: `permissions` derived from initial state; admin gets `admin`; owner gets `owner`. + +## 2. Manifest permission surface +- [ ] 2.1 Extend the manifest schema/validator: `menu[].permission` and `pages[].permission` (string | string[]). +- [ ] 2.2 `CnPageRenderer` filters routed pages by `permission` (mirror `CnAppNav.passesPermission`); a gated page is not reachable without the permission. +- [ ] 2.3 Multiple dashboards: pick the landing dashboard as the highest-priority dashboard page whose `permission` the user satisfies, else the default. +- [ ] 2.4 Component test: vet sees medical menu + vet dashboard; non-vet does not; admin sees all. + +## 3. Author guidance (security boundary) +- [ ] 3.1 Document that `permission` hides navigation only; object-level access MUST be set via OpenRegister `schema.authorization`. Add to the runtime/manifest docs. + +## 4. Pet Store demo wiring +- [ ] 4.1 Add `permission: "group:vets"` to the medical menu item(s) and a `MedicalDashboard` page in the demo manifest. +- [ ] 4.2 (Done in OpenRegister) `medicalRecord.authorization.read/create/update/delete = ["vets"]`. + +## 5. Verification +- [ ] 5.1 Live: as a `vets` user the medical menu + vet dashboard show and medical objects load; as a non-vet they do not; admin sees everything. +- [ ] 5.2 Frontend gates (ADR-004): initial-state (not DOM) for group data; no admin component in vue-router. From 1e8db8ba2dca573c456541f055fcce5744dff4c3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 27 Jun 2026 13:07:24 +0200 Subject: [PATCH 015/391] fix(mcp): make OpenBuild app-building tools work for any caller + real widget types Two fixes so an AI agent can build apps over MCP (the tools previously failed): 1. Org-context: the handlers resolved the `openbuild` register via searchObjectsBySlug() with multitenancy on, so MCP callers whose active org was not the openbuild system org got "register slug not found in caller organisation: openbuild". openbuild is a system-wide register; pass _multitenancy: false (mirrors the REST controllers). Fixes AbstractToolHandler (loadVersion + requireWriteRole), ListAppsHandler, GetAppManifestHandler. 2. Widget allow-list: AddWidgetHandler rejected the real OpenBuild widget types. Replaced the bogus list (stat-counter, chart-bar, ...) with the actual types the runtime renders (header, label, text, image, divider, tile, stat, stats-block, delta, gauge, chart, object-list, table, data, related, files, metadata, integration, map). Verified live via the MCP endpoint: listApps returns pet-store, and createApp -> upsertSchema -> upsertPage -> addWidget(stat/chart/object-list) all succeed. --- lib/Mcp/Handler/AbstractToolHandler.php | 10 +++++-- lib/Mcp/Handler/AddWidgetHandler.php | 34 +++++++++++++++-------- lib/Mcp/Handler/GetAppManifestHandler.php | 2 +- lib/Mcp/Handler/ListAppsHandler.php | 2 +- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/Mcp/Handler/AbstractToolHandler.php b/lib/Mcp/Handler/AbstractToolHandler.php index 4b68518b5..f06aadcbe 100644 --- a/lib/Mcp/Handler/AbstractToolHandler.php +++ b/lib/Mcp/Handler/AbstractToolHandler.php @@ -162,7 +162,9 @@ protected function requireWriteRole(string $appSlug, bool $allowAdminBypass=true } $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]); + // openbuild is a system-wide register (not org-scoped); bypass the + // organisation filter so MCP callers in any org can resolve apps. + $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug], _rbac: true, _multitenancy: false); if (is_array($apps) === false || $apps === []) { return $this->errorResult(error: 'not_found', message: "No virtual app found for slug '{$appSlug}'."); } @@ -487,7 +489,7 @@ protected function extractUuid(array $item): string */ protected function loadVersion(object $objectService, string $appSlug, string $versionSlug): array { - $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]); + $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug], _rbac: true, _multitenancy: false); if (is_array($apps) === false || $apps === []) { return ['error' => 'not_found', 'message' => "No virtual app found for slug '{$appSlug}'."]; } @@ -498,7 +500,9 @@ protected function loadVersion(object $objectService, string $appSlug, string $v $versions = $objectService->searchObjectsBySlug( self::REGISTER_SLUG, 'applicationVersion', - ['application' => $appUuid, 'slug' => $versionSlug] + ['application' => $appUuid, 'slug' => $versionSlug], + _rbac: true, + _multitenancy: false ); if (is_array($versions) === false || $versions === []) { return ['error' => 'not_found', 'message' => "No version '{$versionSlug}' found for app '{$appSlug}'."]; diff --git a/lib/Mcp/Handler/AddWidgetHandler.php b/lib/Mcp/Handler/AddWidgetHandler.php index ca02143c4..d091f66c6 100644 --- a/lib/Mcp/Handler/AddWidgetHandler.php +++ b/lib/Mcp/Handler/AddWidgetHandler.php @@ -42,19 +42,29 @@ class AddWidgetHandler extends AbstractToolHandler * @var array */ private const ALLOWED_WIDGET_TYPES = [ - 'stat-counter', - 'data-table', - 'chart-bar', - 'chart-line', - 'chart-pie', - 'kanban-board', - 'timeline', - 'markdown', - 'iframe', - 'form-embed', + // Content & layout widgets. + 'header', + 'label', + 'text', + 'image', + 'divider', + 'tile', + // Statistic / metric widgets (OpenRegister-data-driven). + 'stat', + 'stats-block', + 'delta', + 'gauge', + 'chart', + // List / table widgets. 'object-list', - 'object-detail', - 'calendar', + 'table', + // Object-context widgets (detail pages). + 'data', + 'related', + 'files', + 'metadata', + // Integration / map. + 'integration', 'map', ]; diff --git a/lib/Mcp/Handler/GetAppManifestHandler.php b/lib/Mcp/Handler/GetAppManifestHandler.php index 3bbecca4e..cfbd4dd7e 100644 --- a/lib/Mcp/Handler/GetAppManifestHandler.php +++ b/lib/Mcp/Handler/GetAppManifestHandler.php @@ -106,7 +106,7 @@ public function handle(array $args): array */ private function resolveApplicationBySlug(object $objectService, string $slug): array { - $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug]); + $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug], _rbac: true, _multitenancy: false); if (is_array($routeResults) === false || $routeResults === []) { return ['error' => 'not_found', 'message' => "No published virtual app found for slug '{$slug}'."]; } diff --git a/lib/Mcp/Handler/ListAppsHandler.php b/lib/Mcp/Handler/ListAppsHandler.php index 985afad26..45639f5ab 100644 --- a/lib/Mcp/Handler/ListAppsHandler.php +++ b/lib/Mcp/Handler/ListAppsHandler.php @@ -61,7 +61,7 @@ public function handle(array $args): array $filters['status'] = $validation['statusFilter']; } - $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters); + $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters, _rbac: true, _multitenancy: false); if (is_array(value: $rawApps) === false) { $rawApps = []; } From e8fdf9db779c13f3b079dbd55e18c15fb7aeab19 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 27 Jun 2026 16:38:30 +0200 Subject: [PATCH 016/391] feat(builder): brand the NC top-bar with the virtual app's name + icon A virtual app is rendered inside the host 'openbuild' app, so Nextcloud's server-rendered top-bar showed 'OpenBuild' + the OpenBuild icon for every built app. There is no supported API to retitle that chrome per virtual app, so builder.js now patches the top-bar DOM (.app-menu__current-app-name, .app-menu__current-app-icon, and the app-menu trigger aria-label) to the app's own name + icon on boot, kept in sync with a MutationObserver so it survives Nextcloud re-rendering the app menu. The icon uses the app's white (dark-slot) variant with the light icon as a fallback. Bumps version 0.5.40 -> 0.5.41. --- appinfo/info.xml | 2 +- src/builder.js | 56 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 6df987686..6ca718a88 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.5.40 + 0.5.41 agpl Conduction OpenBuild diff --git a/src/builder.js b/src/builder.js index e1df6f4ea..eea6572a7 100644 --- a/src/builder.js +++ b/src/builder.js @@ -96,6 +96,57 @@ function translateForApp(key, vars) { return t('openbuild', key, vars) } +/** + * Rebrand the Nextcloud top-bar (app name + icon) to the virtual app's identity. + * + * The global top-bar is server-rendered chrome for the host `openbuild` app, so + * there is no supported API to retitle it per virtual app. We patch the DOM + * directly and keep it in sync with a MutationObserver, because Nextcloud's + * app-menu is a Vue component that can re-render (resize, unified-search and + * notification updates) and would otherwise reset our changes. `apply()` is + * idempotent — it only writes when the value differs — so it never loops on its + * own mutations. + * + * @param {string} appName The virtual app's display name. + * @param {string} appSlug The virtual app's slug, used for its icon endpoint. + */ +function brandTopBar(appName, appSlug) { + if (!appName || typeof document === 'undefined') { + return + } + // The coloured top-bar wants the white (dark-slot) icon; fall back to the + // light icon if the app has no dark variant uploaded. + const iconDark = generateUrl(`/apps/openbuild/icons/${appSlug}-dark.svg`) + const iconLight = generateUrl(`/apps/openbuild/icons/${appSlug}.svg`) + const apply = () => { + const nameEl = document.querySelector('.app-menu__current-app-name') + if (nameEl && nameEl.textContent !== appName) { + nameEl.textContent = appName + } + const iconEl = document.querySelector('.app-menu__current-app-icon') + if (iconEl) { + const src = iconEl.getAttribute('src') + if (src !== iconDark && src !== iconLight) { + iconEl.onerror = () => { iconEl.onerror = null; iconEl.setAttribute('src', iconLight) } + iconEl.setAttribute('src', iconDark) + iconEl.setAttribute('alt', appName) + } + } + const trigger = document.querySelector('[aria-label^="Open apps menu, currently in"]') + if (trigger) { + const label = t('openbuild', 'Open apps menu, currently in {app}', { app: appName }) + if (trigger.getAttribute('aria-label') !== label) { + trigger.setAttribute('aria-label', label) + } + } + } + apply() + const header = document.querySelector('header#header') || document.body + if (header) { + new MutationObserver(apply).observe(header, { childList: true, subtree: true, characterData: true }) + } +} + /** * Fetch the app manifest, build its router, and mount the standalone shell. * @@ -112,12 +163,11 @@ async function boot() { if (data && typeof data === 'object' && Array.isArray(data.pages)) { manifest = data } - // Reflect the app's identity in the browser tab (the global NC top-bar - // still shows the host 'OpenBuild' app — a virtual app is not a real - // Nextcloud app, so its name/icon can't replace the host chrome there). + // Reflect the app's identity in the browser tab and the global NC top-bar. const appName = (manifest.name || manifest.title || slug) if (appName) { document.title = `${appName} – Nextcloud` + brandTopBar(appName, slug) } } catch (e) { // Render an empty (but well-formed) shell; the app simply has no pages. From f378a5855dfe519c888184dcb530bcdb03828f99 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 27 Jun 2026 17:19:16 +0200 Subject: [PATCH 017/391] fix(builder): white paw icon (not cube) + remove top-bar load flash - Use the app's own light icon (/icons/{slug}.svg) forced white via CSS filter instead of the -dark endpoint, which falls back to a generic cube when no white variant is uploaded. - Apply branding early with a slug-humanised name (one shared observer) so the bar flips off 'OpenBuild' before the manifest request resolves. --- src/builder.js | 81 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/src/builder.js b/src/builder.js index eea6572a7..7e3b54197 100644 --- a/src/builder.js +++ b/src/builder.js @@ -96,6 +96,10 @@ function translateForApp(key, vars) { return t('openbuild', key, vars) } +// Top-bar branding state. A single observer drives every (re-)apply so that the +// early slug-based pass and the later manifest-name pass share one watcher. +let topBarBrand = null + /** * Rebrand the Nextcloud top-bar (app name + icon) to the virtual app's identity. * @@ -104,55 +108,92 @@ function translateForApp(key, vars) { * directly and keep it in sync with a MutationObserver, because Nextcloud's * app-menu is a Vue component that can re-render (resize, unified-search and * notification updates) and would otherwise reset our changes. `apply()` is - * idempotent — it only writes when the value differs — so it never loops on its + * idempotent — it only writes when a value differs — so it never loops on its * own mutations. * + * Call it twice: once early with a slug-humanised name (so the bar flips off + * "OpenBuild" before the manifest request resolves), then again with the real + * `manifest.name` to correct it. The second call only updates the shared state + * and re-applies; it does not create a second observer. + * + * The icon uses the app's own light icon (`/icons/{slug}.svg`) forced white with + * a CSS filter, because the coloured header needs a monochrome white glyph and + * apps rarely upload a dedicated white variant (the `-dark` endpoint falls back + * to a generic cube, which is why we do NOT use it here). + * * @param {string} appName The virtual app's display name. * @param {string} appSlug The virtual app's slug, used for its icon endpoint. */ function brandTopBar(appName, appSlug) { - if (!appName || typeof document === 'undefined') { + if (typeof document === 'undefined') { + return + } + const icon = generateUrl(`/apps/openbuild/icons/${appSlug}.svg`) + if (topBarBrand) { + // Refine an existing brand (e.g. slug-name → real manifest name). + if (appName) { + topBarBrand.name = appName + } + topBarBrand.icon = icon + topBarBrand.apply() return } - // The coloured top-bar wants the white (dark-slot) icon; fall back to the - // light icon if the app has no dark variant uploaded. - const iconDark = generateUrl(`/apps/openbuild/icons/${appSlug}-dark.svg`) - const iconLight = generateUrl(`/apps/openbuild/icons/${appSlug}.svg`) - const apply = () => { + const state = { name: appName || appSlug, icon } + state.apply = () => { const nameEl = document.querySelector('.app-menu__current-app-name') - if (nameEl && nameEl.textContent !== appName) { - nameEl.textContent = appName + if (nameEl && state.name && nameEl.textContent !== state.name) { + nameEl.textContent = state.name } const iconEl = document.querySelector('.app-menu__current-app-icon') - if (iconEl) { - const src = iconEl.getAttribute('src') - if (src !== iconDark && src !== iconLight) { - iconEl.onerror = () => { iconEl.onerror = null; iconEl.setAttribute('src', iconLight) } - iconEl.setAttribute('src', iconDark) - iconEl.setAttribute('alt', appName) - } + if (iconEl && iconEl.getAttribute('src') !== state.icon) { + iconEl.setAttribute('src', state.icon) + iconEl.setAttribute('alt', state.name || '') + // The header background is coloured; force any icon to white. + iconEl.style.filter = 'brightness(0) invert(1)' } const trigger = document.querySelector('[aria-label^="Open apps menu, currently in"]') - if (trigger) { - const label = t('openbuild', 'Open apps menu, currently in {app}', { app: appName }) + if (trigger && state.name) { + const label = t('openbuild', 'Open apps menu, currently in {app}', { app: state.name }) if (trigger.getAttribute('aria-label') !== label) { trigger.setAttribute('aria-label', label) } } } - apply() + topBarBrand = state + state.apply() const header = document.querySelector('header#header') || document.body if (header) { - new MutationObserver(apply).observe(header, { childList: true, subtree: true, characterData: true }) + new MutationObserver(state.apply).observe(header, { childList: true, subtree: true, characterData: true }) } } +/** + * Turn a slug into a human-readable title, e.g. `pet-store` → `Pet Store`. Used + * for the early top-bar pass before the manifest (with the real name) loads. + * + * @param {string} value The slug. + * @return {string} + */ +function humaniseSlug(value) { + return String(value || '') + .split(/[-_]+/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + /** * Fetch the app manifest, build its router, and mount the standalone shell. * * @return {Promise} */ async function boot() { + // Flip the top-bar off the host "OpenBuild" identity immediately using the + // slug, so there's no visible "OpenBuild" flash while the manifest (which + // carries the real display name) is still loading. + if (slug) { + brandTopBar(humaniseSlug(slug), slug) + } let manifest = { version: '1.0.0', menu: [], pages: [] } try { let url = generateUrl(`/apps/openbuild/api/applications/${slug}/manifest`) From 65acf062d597d226d6feed79b093959099eeb2c1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 28 Jun 2026 10:11:13 +0200 Subject: [PATCH 018/391] feat(icon): app icon box -> layers-triple OpenBuild composes apps from a layer system (base / admin / user manifest deltas), so the layers glyph fits better than the cube. Swap app.svg, app-dark.svg and app-store.svg to the MDI layers-triple path; colours and hex frame unchanged. --- img/app-dark.svg | 2 +- img/app-store.svg | 2 +- img/app.svg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/img/app-dark.svg b/img/app-dark.svg index 29885ed7d..f0c7563d4 100644 --- a/img/app-dark.svg +++ b/img/app-dark.svg @@ -1,3 +1,3 @@ - + diff --git a/img/app-store.svg b/img/app-store.svg index 7d770d17d..fdcbeedae 100644 --- a/img/app-store.svg +++ b/img/app-store.svg @@ -1,6 +1,6 @@ - + diff --git a/img/app.svg b/img/app.svg index d3c8eeb27..e91b80116 100644 --- a/img/app.svg +++ b/img/app.svg @@ -1,3 +1,3 @@ - + From b632e9b4576ef89363a1ee94e5eb0b7683aeca3d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 29 Jun 2026 09:33:26 +0200 Subject: [PATCH 019/391] ci(openbuild): authenticate composer downloads (GH_COMPOSER_TOKEN) Set COMPOSER_AUTH on every composer install step so Composer fetches dist zips via the authenticated GitHub API instead of anonymous codeload.github.com, eliminating intermittent HTTP 400 throttle failures. Add secrets:inherit to both reusable-workflow callers (app-tests.yml, app-tests-live.yml) so the org-level secret propagates into the reusable jobs. --- .forgejo/workflows/app-tests-live.yml | 1 + .forgejo/workflows/app-tests.yml | 1 + .forgejo/workflows/pre-merge-check-strict.yaml | 2 ++ .forgejo/workflows/tests-live.yml | 2 ++ .forgejo/workflows/tests.yml | 4 ++++ 5 files changed, 10 insertions(+) diff --git a/.forgejo/workflows/app-tests-live.yml b/.forgejo/workflows/app-tests-live.yml index 00f11120e..df924cd2e 100644 --- a/.forgejo/workflows/app-tests-live.yml +++ b/.forgejo/workflows/app-tests-live.yml @@ -35,6 +35,7 @@ permissions: jobs: live: uses: ./.forgejo/workflows/tests-live.yml + secrets: inherit with: app-id: openbuild is-openregister: false diff --git a/.forgejo/workflows/app-tests.yml b/.forgejo/workflows/app-tests.yml index b525e941a..595bd8129 100644 --- a/.forgejo/workflows/app-tests.yml +++ b/.forgejo/workflows/app-tests.yml @@ -28,6 +28,7 @@ permissions: jobs: tests: uses: ./.forgejo/workflows/tests.yml + secrets: inherit with: app-id: openbuild run-e2e: ${{ github.event.inputs.run-e2e == 'true' }} diff --git a/.forgejo/workflows/pre-merge-check-strict.yaml b/.forgejo/workflows/pre-merge-check-strict.yaml index baca99bc6..23c520bf5 100644 --- a/.forgejo/workflows/pre-merge-check-strict.yaml +++ b/.forgejo/workflows/pre-merge-check-strict.yaml @@ -41,6 +41,8 @@ jobs: fetch-depth: 0 - name: Install composer deps + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}' run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs - name: Run lint + phpcs (the enforced gate) diff --git a/.forgejo/workflows/tests-live.yml b/.forgejo/workflows/tests-live.yml index d1a3ed637..a1c96a36d 100644 --- a/.forgejo/workflows/tests-live.yml +++ b/.forgejo/workflows/tests-live.yml @@ -122,6 +122,8 @@ jobs: node-version: ${{ inputs.node-version }} - name: Install composer deps (production) + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}' run: composer install --no-dev --no-interaction --no-progress --prefer-dist - name: Install npm deps + Newman diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml index 88b5833a2..00a26ec9c 100644 --- a/.forgejo/workflows/tests.yml +++ b/.forgejo/workflows/tests.yml @@ -82,6 +82,8 @@ jobs: uses: https://github.com/actions/checkout@v4 - name: Install composer deps + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}' run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs - name: Run unit suite (phpunit-unit.xml) @@ -148,6 +150,8 @@ jobs: || echo "coverage driver: ABSENT (ratchet will no-op; see TESTING-CI-ROLLOUT.md)" - name: Install composer deps + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_COMPOSER_TOKEN }}"}}' run: composer install --no-interaction --no-progress --prefer-dist --ignore-platform-reqs - name: Run unit suite WITH coverage (clover) From 2147ddbb57384ab7164273bd1a6d38b4b6a34455 Mon Sep 17 00:00:00 2001 From: SudoThijn Date: Wed, 1 Jul 2026 12:09:38 +0200 Subject: [PATCH 020/391] fixed tutorial not targeting right elements --- src/manifest.json | 4 ++-- src/views/TemplateGallery.vue | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/manifest.json b/src/manifest.json index 9aed2571a..db5bc2881 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -18,8 +18,8 @@ "steps": [ { "id": "welcome", "sinceVersion": "0.5.9", "placement": "center", "title": "Welcome to OpenBuild", "body": "Let's take a quick spin through the app builder — we'll create your first app from a template so you can see how the pieces fit. You'll do each step yourself.", "target": { "kind": "page", "ref": "Dashboard" }, "advanceOn": { "type": "manual" } }, { "id": "go-apps", "sinceVersion": "0.5.9", "placement": "right", "body": "Apps are the heart of OpenBuild — every app you build lives here. Open Apps from the menu to see what's already there.", "task": "Click Apps in the menu", "target": { "kind": "nav-item", "ref": "VirtualApps" }, "advanceOn": { "type": "route-match", "route": "VirtualApps" } }, - { "id": "go-store", "sinceVersion": "0.5.9", "placement": "right", "body": "The fastest way to start is from a template. Open the Store to browse ready-made apps you can clone and make your own.", "task": "Click Store in the menu", "target": { "kind": "nav-item", "ref": "Store" }, "advanceOn": { "type": "route-match", "route": "Templates" } }, - { "id": "create-app", "sinceVersion": "0.5.9", "placement": "bottom", "allowManualNext": true, "body": "Pick a template and clone it — give your new app a name, then create it. Once it exists you can shape its schemas and pages.", "task": "Clone a template into a new app", "target": { "kind": "page", "ref": "Templates" }, "advanceOn": { "type": "object-created", "register": "openbuild", "schema": "application", "capture": { "applicationId": ":id" } } }, + { "id": "go-store", "sinceVersion": "0.5.9", "placement": "right", "body": "The fastest way to start is from a template. Open the Store to browse ready-made apps you can clone and make your own.", "task": "Click Store in the menu", "target": { "kind": "nav-item", "ref": "Templates" }, "advanceOn": { "type": "route-match", "route": "Templates" } }, + { "id": "create-app", "sinceVersion": "0.5.9", "placement": "bottom", "allowManualNext": true, "body": "Pick a template and clone it — give your new app a name, then create it. Once it exists you can shape its schemas and pages.", "task": "Clone a template into a new app", "target": { "kind": "page", "ref": "templates-grid" }, "advanceOn": { "type": "object-created", "register": "openbuild", "schema": "application", "capture": { "applicationId": ":id" } } }, { "id": "done", "sinceVersion": "0.5.9", "placement": "center", "title": "You're all set", "body": "Nicely done — your first app exists. Open it from Apps to design its schemas and pages, and reopen this tour anytime from the … menu.", "target": { "kind": "page", "ref": "Dashboard" }, "advanceOn": { "type": "manual" } } ] } diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue index d4067a979..229627bc6 100644 --- a/src/views/TemplateGallery.vue +++ b/src/views/TemplateGallery.vue @@ -31,7 +31,7 @@ -