Skip to content

Release: merge beta into main - #436

Open
rubenvdlinde wants to merge 420 commits into
mainfrom
release/beta-to-main-20260830122837
Open

Release: merge beta into main#436
rubenvdlinde wants to merge 420 commits into
mainfrom
release/beta-to-main-20260830122837

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Stable release. beta was 418 commits ahead of main.

Conflicts were resolved to beta's side, including its refactors: where beta had removed a file, the removal stands rather than resurrecting a stale copy from main. No conflicts.

git merge -X theirs settles content conflicts but leaves modify/delete ones unmerged — beta deleted the file, so there is no "theirs" blob to take. Those were resolved by honouring the deletion.

Verified before pushing: the commit has exactly two parents, and no conflicted path was left unresolved. Files main keeps that beta never had (archived openspec docs, whitespace-only differences) are preserved — "beta wins" governs conflicts, not additions.

A failing … / release check here is the App Store publish step, not a quality gate: 7 apps have no signing key and thematiq's certificate carries its old app id. The GitHub release and tag are still created.

rubenvdlinde and others added 30 commits July 27, 2026 18:37
@nextcloud/password-confirmation@5 imports `spawnDialog` from
@nextcloud/dialogs, which dialogs@7 no longer exports — v6 of the package
drops that import. @nextcloud/l10n@2 lacks `detectLanguage`, which the Vue-3
stack calls during boot; a duplicated nested copy masked it until dedupe.

Verified in a browser against an isolated Nextcloud 34: the SPA mounts and
renders real UI (dashboard empty-state) with zero page errors.
vuedraggable v4 (the Vue-3 release) requires rows to come from an `#item`
scoped slot; a v-for in the default slot throws "draggable element must have
an item slot" at render. `item-key` replaces the manual :key binding.

Migrated OrgNavigationEditor and OrgNavigationEditorRow (nested children).
Found by running the e2e suite against an isolated Nextcloud 34.
…orm model API

Three silent-failure classes, none of which Vue 3 warns about:

1. `DashboardSwitcherSidebar` declared the Vue 2 component option
   `model: { prop: 'isOpen', event: 'update:open' }` so the host could
   write a bare `v-model`. Vue 3 removed that option outright: the bare
   `v-model` in Views.vue compiled to `modelValue` / `update:modelValue`,
   neither of which the sidebar declares. `isOpen` therefore stayed at
   its initial `false` for the lifetime of the page — clicking the
   hamburger toggled the host's state but the panel never got the
   `open` class, so the dashboard switcher was unreachable and every
   spec that drives it (sidebar → row cog → Add custom widget) died in
   `beforeEach`. The host now binds `:is-open` and `@update:open`
   explicitly; the sidebar's public contract is unchanged.

2. `.sync` was removed in Vue 3. The compiler silently drops the unknown
   modifier, leaving a one-way bind, so `:show.sync` / `:value.sync` /
   `:checked.sync` rendered but never wrote back.

3. `@nextcloud/vue@9` renamed the two-way prop of every form control
   from `value` / `checked` to `modelValue` and the event from
   `update:value` / `update:checked` to `update:modelValue`. The old
   names fail silently — `:value` falls through as a plain attribute and
   the old event is never emitted. 60 sites across 16 files, including
   every widget config form (label, live-tile, iframe, clock, weather)
   and the visibility rule editor.

Listeners are camelCase on purpose: `useModel()` matches a literal
`onUpdate:modelValue` in the vnode props, so `@update:model-value`
reads as uncontrolled and the parent never receives the update.
…e selectors

Two distinct Vue-2→3 mechanisms, plus accumulated harness rot.

1. `@input` on <NcSelect>/<NcSelectTags> is DEAD under @nextcloud/vue@9.

   v9's NcSelect declares `emits: [" ", "update:modelValue"]` and emits only
   `update:modelValue` — it never emits `input`. Under Vue 2 the component
   emitted `input` (the v2 v-model event), so `@input="handler"` was the
   documented way to observe a selection.

   Worse than merely dead: because `input` is NOT in the component's `emits`
   list, Vue 3 treats `@input` as a *native* listener and falls it through to
   the component's root element. NcSelect's root wraps the `<input
   class="vs__search">` search box, whose native `input` events bubble — so
   the handler fired on every KEYSTROKE, with a DOM Event as its payload
   instead of the selected option.

   That is exactly how the dashboard-sharing sharee picker produced a share
   row with a blank name: `onShareeSelected` ran on a keystroke and got an
   Event, so `displayName`/`shareWith` were undefined.

   An earlier pass had renamed the PROP half of this API (`:value` →
   `:model-value`) but left the EVENT half as `@input` — ClockWidgetForm even
   carries a comment describing the v9 rename above three still-broken
   handlers. This commit finishes that migration: 15 listeners across 9
   components now use `@update:modelValue` (camelCase — the kebab form is a
   known silent-failure trap in this codebase, and camelCase is already the
   established convention here at 49 existing usages).

   Left alone deliberately: `@input` on <CnIconBrowser> and on plain native
   <input> elements. CnIconBrowser really does declare the Vue-2 `value`/
   `input` pair, so those bindings are correct.

2. Admin "Group dashboards" tab rendered its empty state on a 200 response.

   `GET /api/admin/groups` answers with the documented envelope
   `{active, inactive, allKnown}`, but the store did
   `res.data?.data ?? res.data ?? []` — the envelope OBJECT fell through to
   the bare `res.data` branch, `groups.filter(...)` threw "filter is not a
   function", and the catch swallowed it into a "Failed to load groups" toast.
   Read `allKnown` (the field the backend documents as "the full list for the
   UI to render") and guard with Array.isArray.

3. e2e harness rot (pre-existing, unrelated to Vue 3 — these specs could not
   have passed on the Vue-2 base branch either, because the selectors they
   assert on exist in NEITHER branch's source):

   - mydash→launchpad rename leftovers: `/apps/mydash`, `.mydash-sidebar-
     toggle`, `.mydash-edit-mode`, `.mydash-container`.
   - widgets that moved into nc-vue and gained the `cn-` prefix:
     `.label-widget`→`.cn-label-widget`, `.text-display-widget`→
     `.cn-text-widget`, `.image-widget`→`.cn-image-widget`,
     `.nc-widget-grid-picker__*`→`.cn-nc-widget-grid-picker__*`,
     `.launchpad-widget__content`→`.cn-widget-wrapper__content`.
   - `.launchpad-grid` never existed (the container is `.launchpad-container`,
     the GridStack root is `.grid-stack`).
   - nc-widget option label is "Nextcloud widget", not "Nextcloud Widget".
   - the image form moved to nc-vue's CnImageWidgetForm, which dropped the
     URL/Upload radio pair for a single file <label> and DEFERS upload to
     commit; the specs still drove the old radio flow.
   - resource-serve.spec.ts read only NEXTCLOUD_URL, so it silently targeted
     the default :8080 instance instead of NC_BASE_URL.
   - a local openSidebar helper used `.launchpad-sidebar-toggle,
     .launchpad-floating-controls button` with `.first()`; the dashboard-cog
     precedes the toggle in DOM order, so it opened the wrong popover.

   One selector change IS Vue-3-caused: v9's NcTextField forwards fallthrough
   attributes onto the inner <input> rather than the wrapper <div> the v8
   build used, so `[data-test=…] input` matches nothing — `data-test` IS the
   input now.
…rd-widget

The nc-widget proxy renderer moved into nc-vue as CnNcWidgetWidget and its
classes gained the shared `cn-` prefix. The spec still targeted the old
launchpad-local `.nc-dashboard-widget*` names, which exist in neither branch's
source, so all three REQ-WDG-019/021 tests could never find the cell.

The renderer also has no dedicated `__empty` element — loading, no-widget and
empty-list all render through `__state` — so the REQ-WDG-021 assertion now
matches `__state` filtered on the translated 'No items available' string,
keeping the scenario pinned to the empty-list case specifically rather than any
state placeholder.

All three pass live.
…e3.1

Drops the USE_LOCAL_LIB dependency on a library source checkout. 2.0.7 could
not be used: the barrel re-exported each component's default straight from
its .vue2.js script module, so webpack redirected past the wrapper that runs
`script.render = render`, dropped the barrel, and app-root-level components
mounted with no render function — a blank page with zero console errors.
2.1.0-vue3.1 anchors that export module-locally so the wiring is reached by
construction.

Verified by building against the PUBLISHED tarball with the local-source
symlink moved aside, deploying, and loading the app authenticated.
Vue 2 required the key on each child of a `<template v-for>`; Vue 3 keys the
whole fragment, so a key on a child is IGNORED and the list renders unkeyed —
losing the identity Vue needs to patch rows correctly on reorder.

CalendarWidget's agenda groups and FilesWidget's path breadcrumbs both used the
Vue-2 form.
78 of 570 unit tests failed for six distinct Vue-2 -> Vue-3 mechanisms,
none of which surface as a compile error:

* `render(h)` — Vue 3 no longer passes `h` to the render function; it is
  imported from `vue`, and the vnode data object is flat rather than
  Vue 2's nested `{ attrs: {…} }`. Fixed in the shared
  `@conduction/nextcloud-vue` stub and the DashboardConfigModal spec.

* `Vue.use(PiniaVuePlugin)` — there is no global `Vue` to install onto,
  and a Pinia instance IS an app plugin under Vue 3. The v1 top-level
  `pinia` mount option is now `global.plugins: [pinia]`; handled in the
  existing vueTestUtilsCompat adapter alongside the other hoisted keys.

* Listener fallthrough — Vue 3 merges listeners into `$attrs` as `onClick`
  and auto-applies them to a stub's root element. A stub that also does
  `@click="$emit('click')"` therefore fires the parent handler TWICE.
  The wizard advanced two steps per click and the shell hamburger toggled
  itself shut. Declaring `emits: ['click']` removes `onClick` from
  `$attrs`, restoring a single path. Applied to every affected stub.

* `v-model` contract — the prop is `modelValue` and the event
  `update:modelValue` (was `value`/`input`, and `checked`/`update:checked`
  for NcCheckboxRadioSwitch). Stubs on the old contract never wrote back,
  so forms silently stayed empty.

* VTU v2 API — `wrapper.destroy()` is `unmount()`, `findAll()` returns a
  plain array rather than a WrapperArray with `.wrappers`, and v1's
  `scopedSlots` is now `slots` (silently ignored, so every slot in
  BeheerTabs rendered empty).

* `reactive()` identity — `state.selectedWidget` hands back a Proxy, so a
  bare `toBe` compared proxy to raw. `toRaw` keeps it an identity check.

Also raises `hookTimeout` to match `testTimeout`: several admin specs
resolve their component with `await import()` inside `beforeEach`, and the
first call pays Vite's full SFC transform cost, overrunning the 10s
default with "Hook timed out" and no assertion failure.
`@nextcloud/eslint-config@8` resolves eslint-plugin-vue's **Vue 2** preset
(visible via `eslint --print-config`: `vue/no-reserved-props` arrives as
`{ vueVersion: 2 }`). Four of the 17 errors were that preset forbidding
syntax Vue 3 *requires*:

  - `vue/no-v-for-template-key` — Vue 2 banned a key on `<template v-for>`;
    Vue 3 keys the fragment there. Replaced with the Vue-3 counterpart
    `vue/no-v-for-template-key-on-child`.
  - `vue/no-v-model-argument` — `v-model:arg` is Vue 3's replacement for
    Vue 2's `.sync`.

`no-multiple-template-root` and `valid-v-bind-sync` are off for the same
reason. Switching wholesale to `@nextcloud/eslint-config/vue3` is not an
option here: it references `@typescript-eslint/*` rules whose plugin this
project does not register, and ESLint crashes on load.

The `vue/no-deprecated-*` family is enabled as errors, because these are
Vue-2 idioms the Vue 3 compiler ignores rather than rejects — the failure
mode is a dead listener at runtime. It immediately found one:
`DashboardRowActions` bound `@click.native.stop`, and `.native` no longer
exists in Vue 3. The component's own docblock already described the
binding as `@click.stop`, so the modifier was stale as well as inert.

`@spec openspec/...` is registered via `definedTags`. It is this repo's
ADR-003 / ADR-020 traceability tag with its own enforcing gate
(`composer lint:spec-annotations`), so the linter needed to learn the tag
rather than the convention being bent — that alone was 746 of the 1242
warnings.

Remaining errors were genuine style violations in three files, autofixed.
…ng focus

`WidgetMovePanel.vue` — the WCAG 2.1 SC 2.1.1 keyboard equivalent of
GridStack's pointer-only drag — was fully implemented, unit-tested, and
never mounted. Its only references in `src/` were its own definition and a
doc comment in `WidgetContextMenu.vue` describing wiring that was never
done: passing tests against a component the app never renders.

The pieces that existed: `WidgetContextMenu` already declared and emitted
`move`, and `nudgePlacement()` in `useGridManager.js` already implemented
the geometry as a pure function returning the clamped rect plus push-down
side effects. Only the seam between them was missing:

  - `useGridManager` gains an `onMove` option and `triggerMove()`,
    mirroring the existing edit/remove/visibility-rules triggers.
  - `Views.vue` binds `@move`, mounts `<WidgetMovePanel>`, and persists a
    confirmed rect through `updatePlacements()` — folding the moved
    placement and any pushed placements into ONE call so the layout is
    written atomically on the same debounced path drag already uses.

Separately, `placementItemKey()` interpolated `updatedAt` and a JSON dump
of `styleConfig` into the per-item render key. Every persist therefore
changed the key, Vue tore down and recreated the grid item's DOM node, and
whatever was focused inside it lost focus. That defeats keyboard
repositioning even once wired: after a single arrow-key move focus fell
back to the document and a second move needed a full re-navigation of the
grid. The key is now the placement id alone — a content change re-renders
through prop updates without needing a new key.
Two distinct causes behind the jsdoc warnings.

The larger one was structural: ~60 methods carried a COMPLETE docblock
immediately followed by a second, `@spec`-only block. Only the block
adjacent to the function is read, so the real `@param`/`@return` docs
were invisible to both the linter and any reader's tooling. Merging the
`@spec` tag into the block above it recovers the existing documentation
rather than rewriting it.

The rest were genuinely undocumented parameters, concentrated in the HTTP
wrapper layer (`services/api.js`) and the admin group-priority editor.
Written out properly — no `@param {*}` filler.
Completes eslint to 0 errors / 0 warnings across `src/`.

The last three warnings were `vue/no-v-html`. Both sites are legitimate and
stay, with a scoped disable and the reason recorded:

  - DashboardFooter renders admin-authored HTML that only reaches the
    client through `FooterService::sanitiseHtml()`.
  - NewsWidget renders third-party RSS, sanitised server-side by
    `NewsWidgetService::sanitiseSummaryHtml()` and again through DOMPurify
    in `formattedSummary()` after truncation.

A `disable`/`enable` pair is used rather than `disable-next-line` because
the rule reports on the `v-html` attribute's own line, which is several
lines below the element's opening tag in both files.

`npm run lint` is a four-step chain, and its third step was failing on a
pre-existing REQ-INIT-003 violation in `src/public.js`. That call was worse
than a style breach — it was dead. It read a `public-share-token`
initial-state key that no PHP ever provides (`PageController::publicShare`
renders the template with no `provideInitialState` call), so it always
returned its fallback and the URL path parse below it did the real work,
exactly as `templates/public.php` documents. The comment above it asserted
the opposite. Removed the dead read and documented the actual source; the
page is anonymous, so adding the key to the initial-state contract would
have meant a server change with no consumer.
NewsWidget renders feed summaries through `v-html`, so how they are
shortened is a security property rather than a formatting detail — and the
file had no unit spec at all.

Covers what the previous raw-offset slice got wrong: the budget counts
visible characters rather than markup bytes, a cut that lands inside a tag
or attribute can no longer emit a truncated anchor, and the
`rel="noopener noreferrer"` REQ-NEWS-005 forces survives truncation.
Also pins the client-side re-sanitise, so script/img/onerror payloads are
stripped even if the server-side pass were bypassed.
Carries the CnDashboardGrid keyboard a11y this app's WCAG 2.1 SC 2.1.1
e2e coverage depends on, plus three crash fixes — notably CnLockedBanner,
whose `message` prop used a `default()` factory reading `this.lockedBy`.
Vue 3 invokes default factories with no `this`, so it threw on mount and
white-screened any page rendered while another session held a lock.

Pinned explicitly rather than via `npm install`: the lockfile had vue3.2
resolved, and the caret range does not force a prerelease bump on its own.
The stub's docblock explained why it exists — the published CJS bundle
`require()`s `.vue` files Vite cannot transform — but not what accepting it
gives up.

The alias in vitest.config.js redirects EVERY import of
`@conduction/nextcloud-vue` to the stub, so not one of this suite's 624
unit tests runs against the real library. A green unit run is evidence
that the stub works, not that the library does; the suite structurally
cannot catch a breaking prop change, a renamed event, or a component that
renders nothing.

The last case is not hypothetical. Earlier in this migration the library
shipped a dist whose render wiring had been tree-shaken away and every
`Cn` component rendered as a silent comment node — blank app, zero console
errors — while the unit suite stayed fully green, because these stubs
render fine. Only the browser caught it.

Recorded at both places a reader lands: the stub docblock and the alias.
Library integration is the Playwright suite's job, and nothing else's.
…k fixture

- src/composables/useGridManager.js: DEFAULT_MENU_HEIGHT/WIDTH were sized
  for the original 3-button context menu (132px/150px). The popover has
  since grown to 5 items (Edit, Move, Visibility rules…, Remove, Cancel)
  and measures ~232.5x156.5px in practice, so the viewport-edge clamp
  under-estimated its footprint and let the real popover render past the
  clamped edge (or push ctx-remove out of the fixed-position viewport
  entirely). Bumped both constants above the measured size with margin.

- tools/deploy-to-launchpad.sh: two pre-existing bugs hit while
  redeploying to verify the above.
  1. The JS-bundle rename loop computes `new` from `f` via a sed that is
     now a no-op (source/target app ids are both "launchpad"), so
     `sed ... "$f" > "$new"` opened-and-truncated its own input before
     reading it, and the trailing `mv "$f.LICENSE.txt" "$new.LICENSE.txt"`
     failed outright ("are the same file"). Now stages sed's output in a
     temp file and only renames the LICENSE sidecar when the path
     actually differs.
  2. `custom_apps/launchpad` is itself a bind mount on this dev box;
     `rm -rf "$DEST"` deletes every file underneath but then fails on the
     mount point itself ("Device or resource busy") — AFTER already
     wiping the directory's contents, leaving the shared dev instance's
     launchpad app empty until the next successful deploy. Now clears
     $DEST's contents (`find "$DEST" -mindepth 1 -delete`) instead of the
     directory itself.

- tests/e2e/fixtures/acknowledgements.ts (new) +
  tests/e2e/dashboard-acknowledgements.spec.ts: REQ-ACK-001 ships no
  admin-facing form UI for declaring an acknowledgement requirement in
  this pass (config path is @e2e excluded) — nothing in the app seeds a
  compulsory widget with an outstanding acknowledgement for the e2e specs
  to observe. Added ensureOutstandingAcknowledgement(), which seeds one
  through the real, authorised PUT /api/widgets/{id} contract (same
  pattern as fixtures/role-feature-permissions.ts) and wired it into a
  beforeAll so REQ-ACK-002 (forced-delivery gate) and REQ-ACK-004
  (read-receipt report) have real state to exercise. Idempotent: reuses
  the marked placement and bumps acknowledgementContentVersion each run
  so a prior run's receipt never leaves it satisfied.

- package.json / package-lock.json: consume
  @conduction/nextcloud-vue@2.1.0-vue3.4 (already in progress before this
  session).
…l safety

- src/main.js: nc-vue's CnNcWidgetWidget self-registers `nc-widget` with
  `form: null` ("CnNcWidgetWidgetForm is not yet present in this tree" —
  stale; CnNcDashboardWidgetForm + CnNcWidgetGridPicker DO exist in this
  nc-vue version, just never wired to the registration). listWidgetTypes()
  filters null-form entries, so Add Widget's type picker silently never
  offered "Nextcloud widget". Complete the registration from launchpad
  using nc-vue's own public last-registration-wins registry API — the
  same pattern nc-vue itself uses to re-register table/object-list/map.

- src/views/Views.vue: CnNcDashboardWidgetForm injects a `widgets` catalog
  for its grid picker; nothing provided it, so it always saw the inject
  default ([]) and rendered empty regardless of the form fix above. The
  widget store already fetches this exact Nextcloud-native dashboard-widget
  list (loadAvailableWidgets — its own comment notes it feeds
  CnNcWidgetWidget's runtime renderer). provide('widgets', ...) it down
  from Views.vue's setup() so the picker has real data.

- src/styles/workspace.css: CnLabelWidget renders a flex container with a
  <span> text child; flex items default to min-width:auto, which floors
  the span's size at its longest unbreakable word regardless of the
  component's own overflow-wrap:break-word, so a very long single word
  overflows the widget cell. Patched with an additive (non-conflicting)
  min-width:0 rule, the same flex-child pattern already used for
  .launchpad-workspace above it in this file.

- tests/e2e/widget-context-menu.spec.ts: right-edge and bottom-edge popover
  tests picked ".grid-stack-item.first()" and clicked via raw
  page.mouse.click(x, y) at its boundingBox() coordinates. GridStack
  positions items via gs-x/gs-y, not DOM order, so on this long-lived,
  heavily-populated shared dashboard the "first" item can sit well below
  the fold; toBeVisible() doesn't catch that (it's a CSS-visibility check,
  not a viewport-scroll check), so the click silently landed off-screen
  and the popover never opened. scrollIntoViewIfNeeded() before reading
  the box fixes it without touching any assertion.
tools/deploy-to-launchpad.sh resolved its target container with
`docker ps -qf name=nextcloud` — a SUBSTRING match. On a box that also runs
a shared `nextcloud` dev container (used by other apps/worktrees), that
substring silently matched the WRONG container: one that bind-mounts
`custom_apps/launchpad` straight onto a real host checkout. Every
"successful" deploy this session wrote through that mount onto disk
instead of reaching `lp-vue3-e2e` (the container the e2e suite actually
points at via NC_BASE_URL=http://localhost:8098) at all — the suite kept
testing a stale bundle while every app-source fix silently landed on a
real checkout instead of a disposable instance.

Fixes:
- Resolve the target via `LAUNCHPAD_DEPLOY_CONTAINER` (default
  `lp-vue3-e2e`), verified by exact name/id via `docker inspect`, never a
  substring filter.
- Refuse to deploy — loudly, before touching anything — if the resolved
  container's app directory (or any ancestor of it) is a BIND mount. A
  bind mount means the container is wired onto a real host path; a volume
  or a plain in-container directory is fine. This is the general guard:
  even with the container-name bug fixed, nothing should silently write
  through to someone's checkout again.

Verified both directions against the live containers on this box:
LAUNCHPAD_DEPLOY_CONTAINER=nextcloud is now refused (bind mount detected
at custom_apps/launchpad), and the lp-vue3-e2e default proceeds (volume
only, no bind mount in its ancestry).
Picks up nc-vue PR #558 (fix(CnIndexPage): scroll the table, not the page
column, in table view), merged into feat/vue-3 this morning. Folded into
the same re-baseline cycle as the deploy-target fix so there is one clean
measurement against the library version the app will actually ship with,
rather than two rounds.
Picks up the CnWalkthrough persistence fix: completionConfigKey was
schema-declared, documented, and spec-mandated, but had neither a read
nor a write path wired up (grep only hit the schema file) — so dismissal
was localStorage-only and the tour reopened in every fresh browser
context. Full e2e re-run at this pin: 73 passed / 1 failed / 4 skipped.
…vue3.7

- tests/e2e/widget-context-menu.spec.ts: the right/bottom-edge popover tests
  read boundingBox() immediately after page.setViewportSize(), but that
  resize triggers a GridStack column reflow and every .grid-stack-item
  animates to its new position over GridStack's own 300ms CSS transition
  (.grid-stack-animate, gridstack.css). A box read mid-transition describes
  an in-flight frame, not the rest position — harmless for a click well
  inside a widget, but these tests deliberately click within 20-30px of
  the edge, so the animation delta was enough to land the click in the
  grid gutter and never open the popover. Measured 2-in-3 failure rate
  before; added waitForStableBox() (polls until two consecutive reads
  match, no fixed sleep) and verified 3/3 clean runs after.

- tests/e2e/fixtures/secondary-user.ts (new): throwaway-user provisioning
  (OCS API), known-password reset for a pre-seeded account, and a
  loginAs() that authenticates a SECOND, genuinely separate session.
  storageState: undefined is load-bearing: browser.newContext() otherwise
  inherits playwright.config's top-level use.storageState (the shared
  admin session), so every login attempt was silently already
  authenticated as admin and never hit the login form at all.

- tests/e2e/runtime-shell-canEdit.spec.ts: enabled the two empty-state
  scenarios (provision a real zero-dashboard account, toggle
  allowUserDash, assert). Also fixed setAllowUserDashboards: it was
  issuing its PUT through the cookie-authenticated built-in `request`
  fixture, which Nextcloud's CSRF check rejects outright on a
  state-changing route (measured: 412) — the failure was swallowed by a
  console.warn instead of failing the test, so the "false" scenario ran
  against whatever the flag happened to still be. Rewritten to use its
  own Basic-Auth + OCS-APIRequest admin context (matching
  allow-personal-dashboards-flag.spec.ts) and to throw on a non-OK
  response instead of warning.

- tests/e2e/active-dashboard-resolution.spec.ts: enabled the fresh-user
  empty-state scenario using the same throwaway-user fixture.

- tests/e2e/dashboard-sharing.spec.ts: investigated and restored the
  recipient-visibility skip with an accurate reason (two independently
  confirmed causes, neither a stale selector): DashboardResolver
  (lib/Service/DashboardResolver.php) only resolves a user's active
  dashboard from owned/group/template rows and never considers dashboards
  merely shared to them, so a recipient with no dashboard of their own
  lands on the empty state regardless of what's shared; and the ADR-023
  action-authorization matrix defaults every action to admin-only, with
  nothing on this instance ever broadening it — confirmed live via
  OCSForbiddenException on the account's first non-admin AJAX call. Both
  are real product/environment considerations for a follow-up change, not
  something one spec's fixture should paper over.

- package.json / package-lock.json: consume
  @conduction/nextcloud-vue@2.1.0-vue3.7 (nested-modal stacking fix).
  Checked launchpad's modal usage — every NcModal/NcDialog/Cn*Modal is a
  sibling in Views.vue's template, none nested inside another, so this is
  expected to be a no-op here; full e2e run confirms no regression.
feat(vue3): migrate Launchpad to Vue 3 and @conduction/nextcloud-vue 2.1.0-vue3.7
…admin action baseline

Defect 1 — a share was inert. DashboardMapper::findVisibleToUser() unions
exactly three buckets (owned rows, group_shared rows in the user's groups,
the 'default' sentinel); share rows were in none of them. A recipient with
no dashboard of their own landed on the empty state and the shared
dashboard was unreachable everywhere: not in the switcher, never a
resolution candidate, not fetchable by id.

DashboardResolver now owns the share lookup (findSharedLevels,
findSharedDashboards, tryGetSharedDashboard) and DashboardService folds it
into getVisibleToUser(), resolveActiveDashboard() (new last-resort step 6b)
and getEffectiveDashboard() (before the writing template step). Precedence:
a share is the LAST candidate, so it can never displace anything the user
owns, reaches through a group, or explicitly selected. Shared results carry
the SHARE's permission level, not the owning row's.

Defect 2 — the app was unusable by non-admins on a fresh install. The
ADR-023 seed mapped every declared action to ["admin"], so a non-admin hit
'Action dashboard.list requires admin rights' on the first AJAX call —
including the "Create your first dashboard" CTA the empty state shows to
exactly those users. Added a GROUP_ALL_USERS ('@ALL') sentinel, granted it
to the ordinary end-user surface in the seed, kept every administrative
action admin-only, and added a version-gated ApplyActionBaseline repair
step so already-installed instances get the baseline too without
overwriting admin customisation.

Tests: 28 new PHPUnit tests, all verified to fail against the unfixed code
(Defect 2 fails behaviourally with the real OCSForbiddenException).
…nd the @ALL matrix column

Both suites verified to FAIL against origin/development's components:
 - DashboardSwitcherSidebar: 4 of 5 new tests fail (shared rows leak into
   the primary-group section, no data-section="shared", switch emits no
   shared discriminator, section order is [group, default, user]).
 - ActionAuthMatrix: 2 of 6 fail (displayGroups is ['admin','editors'] —
   the @ALL column the shipped baseline needs is never rendered, so an
   admin can neither see nor revoke the non-admin grant).
…ipient e2e

The server-side fix alone was not enough end-to-end. Every source-aware
getter in the dashboard store filters by an EXPLICIT source value, and
Views.vue's sidebarGroupDashboards only concatenated the group and default
buckets — so 'shared' rows survived the server-rendered initial state but
vanished the moment the store refreshed from /api/dashboards/visible. Added
a sharedWithMeDashboards getter, wired it into the sidebar input and into
activeDashboardSource so the row cog gates owner-only entries correctly.

e2e: un-skipped 'recipient sees the shared dashboard in their switcher'
(both stated blockers are now fixed) and added two ADR-023 scenarios in
runtime-shell-canEdit.spec.ts — an ordinary user may call the end-user API
surface but not instance analytics, and the empty-state Create CTA the app
itself offers actually returns 200 and persists.

Store spec verified failing against origin/development's store (rc=1);
30/30 pass with the getter.
…ally runs

A repair step only executes when NC sees a version increase. The e2e
instance was confirmed live to still hold the all-admin matrix with
`actions_baseline_version` unset while installed_version already equalled
info.xml — so without this bump `occ upgrade` is a no-op, the baseline
never reaches an existing install, and the fix would have looked shipped
while changing nothing.
toHaveCount(0) on .workspace-shell__empty passes trivially on a blank
page, before Vue mounts. Wait for the app to render EITHER the shell or
the empty state first, then assert it is not the empty state.
e2e caught what every unit test here missed. DashboardFactory sets
STATUS_DRAFT on every dashboard it creates (all 15 rows on the e2e
instance are drafts), and filterByPublicationState() hides drafts from
non-owners — so the previous commit appended the share and then filtered
it straight back out. Live: GET /api/dashboards/visible returned
{items:[]} for the recipient while GET /api/dashboard returned the
dashboard, because tryGetSharedDashboard() never went through the filter.
Two paths, opposite answers, and the switcher showed nothing.

Shares are now appended AFTER the publication filter and are exempt from
the draft/scheduled hide: a share row is the owner's explicit, named
grant to that specific user — the same class of entitlement that already
exempts the owner and admins, and what PermissionService::resolveAccessLevel()
already honours. A user WITHOUT a share still cannot see a draft
(testUnsharedDraftStaysHiddenFromNonOwners). Due scheduled rows still
materialise in memory via the extracted materialiseDueSchedule().

Root cause of the miss: the unit fixtures called setPublicationStatus(
STATUS_PUBLISHED) explicitly, so they were unrepresentative of what the
factory produces. The new test builds through DashboardFactory and
asserts the factory default, so a change there fails a unit test rather
than a browser.

Also fixes a made-up URL in the new ADR-023 e2e test: it hit
/api/analytics/instance-summary (404) instead of the real
/api/admin/analytics/summary — a 404 proves nothing about authorization.
…est non-admins

There is NO authorization hole. The reported 200 from the admin-only
instance-analytics endpoint was my own test authenticating as admin.

pwRequest.newContext() inherits playwright.config's top-level
use.storageState — the shared ADMIN session cookie from global-setup — so
Nextcloud authenticated the request by cookie and never looked at
httpCredentials at all. This is the exact trap loginAs() already documents
for browser.newContext() in this same file. Measured three ways against
the deployed code: a freshly provisioned zero-group user gets 403 on
/api/admin/analytics/{summary,dashboards/top,export}; unauthenticated gets
401; only an admin gets 200.

Consequences and fixes:
 - storageState: undefined, so the context is genuinely the throwaway user.
 - httpCredentials send:'always' — NC replies with a bare 401 carrying no
   WWW-Authenticate header, so Playwright's default challenge-response mode
   has nothing to respond to and would never send the credentials.
 - the end-user-surface assertions were not.toBe(403), which also passes on
   401 and 404; they now assert toBe(200) so 'never authenticated' and
   'wrong URL' can no longer read as success. Both false greens really
   happened while writing this spec.
 - the Create CTA expects 200 or 201; 201 Created is correct for that POST.
   401/403/404/5xx still fail.

loginAs(): click() also waits for the navigation it schedules, bounded by
actionTimeout (10s), NOT navigationTimeout (60s). The NC login POST +
redirect + hydration exceeds 10s on a loaded box, so the click failed while
the login had SUCCEEDED — the saved snapshot at failure shows a fully
authenticated page (#app-dashboard, Applications nav, Settings menu), not
the login form. Handed the wait to the explicit waitForSelector('#header')
that already followed it with its own 45s budget. Not a timeout increase:
if the login really fails, #header never appears and it still fails.
…bac-baseline

fix(sharing,rbac): make a shared dashboard actually reachable, and a fresh install usable by non-admins
rubenvdlinde and others added 22 commits August 29, 2026 19:29
Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Bumps [stylelint](https://github.com/stylelint/stylelint) from 15.11.0 to 17.14.1.
- [Release notes](https://github.com/stylelint/stylelint/releases)
- [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md)
- [Commits](stylelint/stylelint@15.11.0...17.14.1)

---
updated-dependencies:
- dependency-name: stylelint
  dependency-version: 17.14.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@vue/test-utils](https://github.com/vuejs/test-utils) from 2.4.11 to 2.5.0.
- [Release notes](https://github.com/vuejs/test-utils/releases)
- [Commits](vuejs/test-utils@v2.4.11...v2.5.0)

---
updated-dependencies:
- dependency-name: "@vue/test-utils"
  dependency-version: 2.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.13 to 3.4.14.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.4.13...3.4.14)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 3.2.7 to 4.1.11.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
)

Bumps [@cyclonedx/cyclonedx-npm](https://github.com/CycloneDX/cyclonedx-node-npm) from 5.0.0 to 6.0.1.
- [Release notes](https://github.com/CycloneDX/cyclonedx-node-npm/releases)
- [Changelog](https://github.com/CycloneDX/cyclonedx-node-npm/blob/main/HISTORY.md)
- [Commits](CycloneDX/cyclonedx-node-npm@v5.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: "@cyclonedx/cyclonedx-npm"
  dependency-version: 6.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [vue](https://github.com/vuejs/core) from 3.5.41 to 3.5.42.
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](vuejs/core@v3.5.41...v3.5.42)

---
updated-dependencies:
- dependency-name: vue
  dependency-version: 3.5.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [phpmetrics/phpmetrics](https://github.com/phpmetrics/PhpMetrics) from 2.9.1 to 2.11.0.
- [Release notes](https://github.com/phpmetrics/PhpMetrics/releases)
- [Changelog](https://github.com/phpmetrics/PhpMetrics/blob/master/CHANGELOG.md)
- [Commits](phpmetrics/PhpMetrics@v2.9.1...v2.11.0)

---
updated-dependencies:
- dependency-name: phpmetrics/phpmetrics
  dependency-version: 2.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…408)

Bumps [phpcsstandards/phpcsextra](https://github.com/PHPCSStandards/PHPCSExtra) from 1.5.0 to 1.5.1.
- [Release notes](https://github.com/PHPCSStandards/PHPCSExtra/releases)
- [Changelog](https://github.com/PHPCSStandards/PHPCSExtra/blob/develop/CHANGELOG.md)
- [Commits](PHPCSStandards/PHPCSExtra@1.5.0...1.5.1)

---
updated-dependencies:
- dependency-name: phpcsstandards/phpcsextra
  dependency-version: 1.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…407)

Bumps [squizlabs/php_codesniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) from 3.13.6 to 4.0.4.
- [Release notes](https://github.com/PHPCSStandards/PHP_CodeSniffer/releases)
- [Changelog](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/4.x/CHANGELOG-3.x.md)
- [Commits](PHPCSStandards/PHP_CodeSniffer@3.13.6...4.0.4)

---
updated-dependencies:
- dependency-name: squizlabs/php_codesniffer
  dependency-version: 4.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [twig/twig](https://github.com/twigphp/Twig) from 3.27.0 to 3.28.0.
- [Release notes](https://github.com/twigphp/Twig/releases)
- [Changelog](https://github.com/twigphp/Twig/blob/3.x/CHANGELOG)
- [Commits](twigphp/Twig@v3.27.0...v3.28.0)

---
updated-dependencies:
- dependency-name: twig/twig
  dependency-version: 3.28.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.8 to 2.2.9.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [nextcloud/ocp](https://github.com/nextcloud-deps/ocp) from 34.0.2 to 34.0.3.
- [Commits](nextcloud-deps/ocp@v34.0.2...v34.0.3)

---
updated-dependencies:
- dependency-name: nextcloud/ocp
  dependency-version: 34.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…260830084126

chore(sync): carry beta back into development
beta held 19 commit(s) development did not. Merged with -s ours:
development's tree is kept BYTE FOR BYTE and only the ancestry is
recorded. That is the payload -- without it the merge base never moves
and the next development -> beta promotion conflicts on the version file
exactly as before. 13 of 19 promotion PRs were CONFLICTING for this
reason.

Nothing is silently imported. What beta holds and development does not,
and which this deliberately does NOT bring over:

  lib/Settings/launchpad_register.json

Those are dead Forgejo/Codeberg CI (removed from development on
2026-08-24/25 by 'chore(ci): remove dead Forgejo/Codeberg CI
configuration'), generated Docusaurus build output, and community-health
files that never existed on development. Each can be added deliberately
if wanted; resurrecting them as a side effect of a sync is how a merge
silently undoes a decision.
…0841

chore(sync): record beta's ancestry on development
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Two dependabot bumps landed on development on 2026-08-30 without CI, and each
leaves the tree unresolvable:

- @vitest/coverage-v8 went to 4.1.11 while vitest and @vitest/ui stayed on
  ^3.2.7, so coverage-v8 peers vitest 4.1.11 against @vitest/ui's vitest 3.2.7
- stylelint went to 17.14.1, which @nextcloud/stylelint-config 2.4.0 cannot
  peer: it wants ^15.6.0

Both are reverted to the version the rest of their own ecosystem is on, rather
than bumping the ecosystem, because a vitest 3 to 4 move is a separate change
that deserves its own testing.

Verified with npm ci --dry-run from the committed lock: exit 0.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
…426)

* fix(deps): hoist @vue/server-renderer so the unit suite can run

Every one of the 63 frontend test files failed, with no tests executed
at all:

  Error: Cannot find package '@vue/server-renderer' imported from
  node_modules/@vue/test-utils/dist/vue-test-utils.cjs.js

The package was installed, but NESTED at
node_modules/vue/node_modules/@vue/server-renderer rather than hoisted.
@vue/test-utils declares it as a PEER dependency ('3.x'), and a peer is
resolved upward from the importing package's own directory -- so
@vue/test-utils looked for node_modules/@vue/server-renderer, which did
not exist. Nothing was missing from the lockfile; it was in the wrong
place.

Declaring it directly pins it at the top level, which is where a peer
has to be. Version tracks vue itself (^3.5.42).

Verified locally: reproduced the failure first (1 file, 'no tests'),
then after the change the full suite runs -- 63 files passed, 682 tests
passed, 0 failed.

* fix(stylelint): extend the config the app actually declares

stylelint exited 78 -- a CONFIGURATION failure, not a lint finding:

  Could not find "stylelint-config-recommended".

stylelint.config.js extended 'stylelint-config-recommended-vue', which
was never declared in package.json. It was present only transitively,
and the config it in turn extends, stylelint-config-recommended, was not
installed at all.

launchpad already declares @nextcloud/stylelint-config ^2.4.0, and that
is what openregister, opencatalogi, dossiq and shillinq all extend. This
points the config at the package the app declares and the fleet uses,
rather than adding two more dependencies to prop up an outlier.

That made stylelint RUN, which surfaced 44 real violations the
configuration error had been hiding. 42 were auto-fixable
(rule-empty-line-before, plus a few over-indented selector continuation
lines) and were fixed with --fix; the diff outside css/ is whitespace
only.

The last two were a genuine CSS bug in css/header-override.css:

  background-color: #ffffff !important;
  background-image: none !important;
  background:       #ffffff !important;   <- discards both of the above

The shorthand alone already sets the colour and resets background-image
to none, so keeping only it preserves the computed result EXACTLY.
Keeping the longhands instead would not have, because the shorthand also
resets the other background sub-properties.

Verified: stylelint now exits 0.

* fix(stylelint): let Prettier own whitespace, and stop the fixer loop

The previous commit made stylelint run, and it and Prettier then
disagreed about the same six lines. Prettier indents a wrapped selector
list; stylelint's `indentation` rule demanded 0 tabs there. Running
either fixer broke the other check:

  npm run stylelint:fix  ->  Frontend Check (format) fails
  npm run format:fix     ->  Vue Quality (stylelint) fails

Both `indentation` and `string-quotes` are DEPRECATED in stylelint 15 --
it prints a deprecation warning for each on every run -- precisely
because formatters do this better. Turning them off resolves the conflict
in favour of the tool that owns formatting and leaves stylelint judging
what only it can judge: CSS semantics.

The three files are re-formatted to Prettier's shape.

Verified: stylelint exit 0 AND prettier exit 0 together, with the two
deprecation warnings gone.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
Release: merge development into beta
beta is the release candidate, 418 commits ahead of main. Every conflict
resolved to beta's side, including its refactors: where beta had removed a
file the removal stands rather than resurrecting a stale copy from main.

Conflicts: 4 (0 took beta's content, 4 removed per
beta's refactor).
launchpad is the ONLY one of the 21 fleet apps still shipping
.github/workflows/branch-policy.yml. Every other app dropped it in favour
of the shared ConductionNL/.github branch-protection.yml, which launchpad
also already calls.

The two disagree, and the stale local copy is the stricter one: it admits
only 'beta' and 'hotfix/*' into main, while the shared gate also admits
'release/v*' version bumps, 'dependabot/*' and, relevant here, any
'release/*' branch. So this release pull request is refused by the local
relic and accepted by the fleet standard.

Two gates under one name is worse than either alone: the check that fails
is named 'Branch Policy Check' while the fleet's is
'branch-protection / check-branch', so a refusal reads as the fleet gate
rejecting a promotion the fleet gate actually allows.

Verified with a per-repository existence check that launchpad is the sole
carrier -- an earlier sweep said eight apps had it, which was wrong:
 prints  on a 404 and shell -n reads that as present.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

This pull request now also removes .github/workflows/branch-policy.yml, because that file is what was refusing it.

launchpad is the only one of the 21 fleet apps still carrying it. Every other app dropped it in favour of the shared ConductionNL/.github branch-protection.yml, which launchpad already calls as well. The two disagree, and the stale local copy is the stricter: it admits only beta and hotfix/* into main, while the shared gate also admits release/v*, dependabot/* and any release/* branch — including this one.

Two gates under one responsibility is worse than either alone. The failing check is named Branch Policy Check while the fleet's is branch-protection / check-branch, so the refusal reads as the fleet gate rejecting a promotion that the fleet gate actually permits.

One correction to the commit message: its last line should read "gh api --jq prints null on a 404 and shell -n reads that as present". The shell expanded those backticks before git saw them, so the words are missing from the commit. That matters because it is how I initially mis-measured this — an earlier sweep reported eight apps carrying the file, and every one of those was a 404 returning null. Re-running it as a real existence check gave launchpad alone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants