Skip to content

feat: Nuxt 4 SPA migration - Laravel becomes API-only - #898

Open
edwh wants to merge 383 commits into
developfrom
nuxt-client
Open

feat: Nuxt 4 SPA migration - Laravel becomes API-only#898
edwh wants to merge 383 commits into
developfrom
nuxt-client

Conversation

@edwh

@edwh edwh commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

What's included

  • Nuxt 4 SPA: Vue 3, Pinia, bootstrap-vue-next (Bootstrap 5), @nuxtjs/i18n, Vitest, Playwright e2e. All pages ported: dashboard, groups (+ map/nearby/all/create/edit), events (+ create/view/devices/attendees), fixometer, profile (view + edit tabs), admin reference-data, user/all, networks, notifications, auth.

  • Auth: Sanctum bearer + dual guard auth:sanctum,api; an SSO bridge (/auth/bridge) minting a web session for Discourse SSO + MediaWiki silent login.

  • Infra: docker-compose split into Nuxt + API containers; CI four-job topology (build, build-client, e2e-client); client translations exported from lang/*.php.

  • Design parity with the live site (G6) — reviewed page-by-page against production and matched:

    • Login: brand .panel/.panel__orange cards with the offset drop-shadow (ported the legacy _panels.scss into the global layer — no component overrides).
    • Dashboard: "Welcome to Restarters" hero + doodles, two-column Your Groups / Upcoming events panel, repair photo, blue info panel, amber "Getting started" call-out, and the restored Add Data group→event picker.
    • Fixometer: the teal stat-card grid + the inline searchable POWERED/UNPOWERED repair-records table with filter collapsibles.
    • CSS consolidation: the dashed content divider promoted to a global .content-divider; brand cards global, only page-specific layout scoped.
    • Assets rationalised to WebP (icons stay SVG); the missing /images/* set populated.
  • API security review (verified against the pre-migration controllers) — fixed a critical device IDOR, an open redirect in the SSO bridge, an event-visibility regression (unmoderated-group events were public), and a password-reset token replay; each with a regression test. See docs/nuxt-migration/findings/security-review-api.md.

Code Quality Review

  • The pinned web-route surface (ApiOnlyRouteSurfaceTest) and per-endpoint OpenAPI response validation guard against surface/shape drift.
  • New endpoints follow v2 conventions (explicit validated field lists — no mass assignment; role/api_token excluded from $fillable; Eloquent bindings — no raw SQL).
  • The Nuxt client is jQuery-free; jQuery survives only in the MediaWiki skin (wiki.js) and the embeddable widget pages' CDN libs — both outside the Vue/Nuxt app.

Test Plan

  • CI (build phpunit, build-client vitest 882 + lint + build, e2e-client Playwright) green on the branch head.
  • New coverage for every restored/added endpoint and flow (APIv2PublicProfileTest, APIv2DeviceUpdateAuthTest, APIv2EventVisibilityTest, SsoBridgeTest, AuthEndpointsTest, DashboardAddData.spec, e2e for dashboard/device/event flows).

Future Improvements

  • Remaining non-API web surface (see docs/nuxt-migration/findings/): the SSO bridge is irreducibly a web route; the redirectors + catch-all could move to nginx; admin preview-deploy should move into the SPA + an /api/v2 endpoint (it currently can't authenticate the SPA user, which is a session-vs-token mismatch). The anonymous export/calendar feeds and embeddable stats widgets are kept for external consumers/partners.
  • Security hardening backlog documented in security-review-api.md (repair-directory read gate, dead v1 /api/groups/ dump, dead draft-image copy path, tus filename-collision + disk-fill sweep, throttle gaps). The public group directory + shareable-link exposure were reviewed and deliberately left as-is.

edwh and others added 4 commits July 18, 2026 09:57
Live-vs-local parity review (docs/nuxt-migration/findings/g6-visual-review.md)
against the running restarters.net site found several cutover regressions:

- Profile pages (/profile, /profile/{id}) always showed "profile couldn't be
  found": the client calls GET /api/v2/users/{id}, which didn't exist. Add
  UserController::getPublicProfilev2 + route, returning the documented PII-safe
  shape (name/avatar/role/location/groups/skills/bio, NOT email) - any logged-in
  user may view any profile, matching the always-public legacy Blade page.
- The /group/all tab rendered the raw key GROUPS.ALL_GROUPS: add the
  client-only key client.groups.all_tab (en/fr/fr-BE) and point GroupsTabsNav
  at it. Added a label-resolution test (the old test only checked hrefs, which
  is why the missing key slipped through).
- client/public/images/ was never created, so every /images/* fallback
  (placeholder avatar, the icon SVGs, onboarding photos) rendered as a broken
  image. Populate it.
- Bootstrap 4 spacing utilities (mr-/ml-/pl-/pr-) are dead under bootstrap-vue-
  next's Bootstrap 5, so nav and footer links ran together -> me-/ms-/ps-/pe-.

Asset rationalisation (per request): raster -> WebP (placeholder-avatar 8K->4K,
onboarding photos ~280K->~38K each, via GD); the UI icons stay SVG (vector CSS
backgrounds - the "unless a reason not to" exception); line-endings LF-
normalised; kebab-case names; references updated across the client.

Tests: tests/Feature/Users/APIv2PublicProfileTest, GroupsTabsNav label test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate

Careful review of the API surface (docs/nuxt-migration/findings/
security-review-api.md), fixing the confirmed exploitable findings:

- CRITICAL - device IDOR: PATCH /api/v2/devices/{id} checked permission on the
  body eventid but overwrote the device loaded by the URL id, with no check it
  belonged to that event. Device ids are sequential, so a host of event A could
  PATCH a stranger's device with eventid=A and reassign/overwrite it. Now also
  require edit permission on the device's current owning event (matching
  deleteDevicev2). Test: APIv2DeviceUpdateAuthTest.
- HIGH - open redirect: BridgeController::safeRedirect used a bare
  str_starts_with, so https://<frontend-origin>.attacker.com passed the
  allowlist - and it fires right after establishing a real login session.
  Match on an origin/path boundary. Test:
  SsoBridgeTest::testRedirectAllowlistBlocksSuffixBypass.
- HIGH - event visibility regression: GET /api/v2/events/{id}, /attendees and
  /devices had no gate, so anyone could read full venue / exact lat-long /
  online-meeting link / attendee names / device notes for events on unmoderated
  (spam-risk) groups. Restore the legacy userHasViewPartyPermission gate (404);
  events on approved groups stay fully public. Test: APIv2EventVisibilityTest.
- MEDIUM - password-reset token replay: resetPasswordv2 never rotated recovery/
  recovery_expires, so an intercepted/forwarded link stayed valid for 24h even
  after use. Rotate it on reset (single-use), matching updateMyPasswordv2. Test:
  AuthEndpointsTest::testResetPasswordTokenCannotBeReplayed.

Remaining findings (repair-directory read gate, dead v1 /api/groups/ table
dump, dead draft-image IDOR path, tus filename collision + disk-fill, missing
throttles, public group directory showing unapproved groups) are documented
for follow-up. The group-directory visibility question needs a product decision
and was left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…guard

The H2 event-visibility gate (getEventv2/attendeesv2/devicesv2) used
$request->user(), which only checks the default 'web' guard. The SPA
authenticates with a sanctum bearer token, so on these auth-middleware-free
routes the acting user came back null and the gate 404'd even an admin or host
- which broke the e2e device-moderation flow (setUpApprovedEvent could no
longer load /party/view/{id} to approve an event on a freshly-created,
still-unapproved group). CI #4829 build+build-client were green; e2e-client
caught this.

Resolve the acting user across guards (web -> sanctum -> api, non-throwing) via
a new optionalUser() helper, matching the controller's own getUser(). Events on
approved groups were always public regardless, so only the moderation path was
affected.

Test: APIv2EventVisibilityTest::testUnapprovedGroupEventVisibleToAdminViaBearerToken
uses a real bearer token (no actingAs) and 404s an anonymous caller - it fails
on the old $request->user() path and passes now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…page

The event-visibility gate (H2, commit 837d454) correctly hides events on
unapproved (unmoderated) groups from everyone but their host/coordinator/admin.
The follow-group e2e ("Following the hosting group from the event page joins
it") had a non-member (USERS.host) view an event on a still-unapproved group,
so with the gate the event page now 404s and the follow button never renders
(CI #4830 e2e-client: 1 failed, 62 passed).

Add an approveGroup() util (mirrors approveEvent, via GroupForm's
group-form-moderate select + PATCH /api/v2/groups/{id}) and approve the group
in that test - matching the realistic flow: you follow a group once it's been
moderated/made public. Test-only change; locally event.test.js is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread app/Http/Controllers/Auth/BridgeController.php Fixed
Comment thread app/Http/Controllers/Auth/BridgeController.php Fixed
Comment thread tests/Feature/Auth/DualGuardTest.php Fixed
Comment thread tests/Feature/Users/APIv2UserEventsTest.php Fixed
Comment thread client/app/stores/devices.js Fixed
The login/dashboard templates already reference .panel / .panel__orange /
.panel__blue, but those classes were never ported from the legacy
resources/sass/_panels.scss - so the signature brand card (white fill, thin
black border, hard 6px offset drop-shadow) and the amber/blue variants were
missing, leaving flat bordered boxes vs the live site.

Add client/app/assets/css/_panels.scss (ported verbatim, $brand-black -> $black)
+ the $brand-blue token, imported in restarters.scss alongside the other
component base layers. No component-scoped CSS: the existing template classes
now resolve. Fixes the login page's amber welcome panel + card shadows; the
same classes back the dashboard panels (D2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown

Preview: https://restarters-pr-898.fly.dev

❌ Deploy completed but startup reported a failure: migrate failed - check machine logs.

  • Password-protected test environment (usual dev gate password); commit 8dec22458c06654970491ae93f8df7af6aa9fc4a merged with develop.
  • If you see a "warming up" page, give it a few minutes - it refreshes itself.
  • The preview database is refreshed on every deploy and may reset at any time; anything you create here is disposable.
  • Emails go to shared Mailpit, never to real recipients. Image uploads are disabled.
  • The app suspends when idle; the first request after a pause can take a moment.

Replicate the live restarters.net dashboard and Fixometer, porting the legacy
Vue 2 components (resources/js/components/Dashboard*/Fixometer*, read from
07e6abd^) faithfully into the Nuxt client.

Dashboard (D2):
- "Welcome to Restarters" hero flanked by the arrow/confetti doodles; Your
  Groups + Upcoming events in a two-column .panel; a right sidebar with the
  repair photo, blue "global community" info panel and the amber "Getting
  started" call-out (hand doodle + arrow-doodle bullets) - all using the
  global .panel/.panel__orange/.panel__blue classes from _panels.scss.
- Restored 11 sidebar lang keys the Phase F prune dropped (values from history).

Fixometer (D3):
- ImpactStats rewritten as the teal stat-CARD grid (near-black #222 border +
  offset shadow, teal values - matching the live cards), LatestRepairs as the
  teal hero card; "Our Global Impact" intro; the repair-records table
  (DevicesSearchTable) embedded INLINE below the stats with the POWERED/
  UNPOWERED tabs + ITEM & REPAIR INFO / EVENT INFO filter collapsibles +
  Download-all-data (admin) - matching the legacy single-page layout.
  /device/search still renders the same component standalone.
- Restored 8 fixometer lang keys from history.

CSS consistency (per review): the dashed content divider was re-declared in 6
components - promoted to a global .content-divider utility (_type.scss);
normalised LatestRepairs' #000 -> #222. Brand cards stay global (.panel);
only genuinely page-specific layout is scoped.

Also fixes 6 placeholder-avatar refs (.png -> .webp) missed when the asset
rationalisation landed - HEAD still pointed at the deleted .png.

Assets: dashboard + fixometer doodles/photos/icons copied into
client/public/images. Tests: vitest 880/880, dashboard e2e green, eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread .circleci/config.yml Fixed
Comment thread .circleci/config.yml Fixed
Comment thread .circleci/config.yml Fixed
Comment thread playwright.client.config.js Fixed
Comment thread client/Dockerfile Fixed
edwh and others added 3 commits July 18, 2026 16:51
app.js (+ its imports components/dropdown.js and misc/notifications.js) was
the old Blade app's global jQuery: Bootstrap-tab hash sync, *-search form
hash injection, a custom dropdown toggle and notification read-toggling. Post
API-only cutover the only pages that still load it (via footer.blade.php's
@Vite) are the error pages, the embeddable stats widgets and admin
preview-deploy - none of which have the tab/search/dropdown/notification
markup those handlers target, so the code is dead.

Removed the @Vite include from footer.blade.php, dropped app.js from
vite.config.js's input, and deleted app.js + dropdown.js + notifications.js.
Vite build still green (stats-share.js + wiki.js + CSS).

The Nuxt client is already jQuery-free. jQuery now survives only in
resources/wiki/js/wiki.js (the MediaWiki skin, which runs inside MediaWiki
and uses its jQuery) and the header_plain CDN libs (Bootstrap 4 / ekko /
leaflet) that the embeddable widget/stats Blade pages still load - both
outside the Vue/Nuxt app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live dashboard has an "Add Data" section (legacy DashboardAddData.vue): a
group -> event picker that jumps to the chosen event's view where the user adds
repair data. The Nuxt dashboard had dropped it (and the fixometer's "Add Data"
button just linked here), so the function was missing.

Add DashboardAddData.vue (prop-driven, matching the sibling dashboard
components): group select -> event select (that group's events, newest first)
-> Add button linking to /party/view/{id}, shown only when the user has an
event. The dashboard page fetches GET /api/v2/users/me/events and passes it in
alongside your_groups. Restored the 3 pruned lang keys (add_data_heading /
see_your_impact / add_data_add) from history in all 3 locales.

Test: DashboardAddData.spec.js (group filtering, newest-first ordering, add
link). vitest 882/882, eslint clean, visually confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The D2 dashboard port restored all 11 legacy dashboard.sidebar_* keys, but
sidebar_kit1/kit2/help/let_us_know are never used: in the legacy they sit
inside a `d-none d-md-block` panel yet are themselves `d-md-none`, so they
render on neither mobile nor desktop (dead markup) - which is why the live
desktop sidebar shows only sidebar_intro_1. The Nuxt port faithfully omits
them, so CheckTranslations (CI #4832 build job) flagged them as unused. Remove
the 4 keys from en/fr/fr-BE and regenerate the client locale JSONs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e scss test

CI #4833 build-client (vitest) failed for two reasons introduced/exposed by the
Add Data work:
- dashboard.vue's onMounted now also calls eventsStore.fetchMyEvents(); the
  dashboard page test only mocked dashboardStore.fetch, so the un-mocked call
  hit the (absent) $api and threw an unhandled rejection -> non-zero vitest exit
  (the assertions still "passed", which is why a bare test-count check missed
  it locally). Mock fetchMyEvents + stub BFormSelect.
- restarters-scss.spec.js compiles the whole stylesheet with sass.compile in
  each of its 3 tests, but only the first had the 60s timeout the others need;
  the 5s default flaked out under load. Give all three 60_000.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Blade admin/preview-deploy page sat behind Laravel's web-session `auth`
guard and checked Auth::user(), but the post-cutover SPA admin holds a sanctum
bearer token and has no web session - so the page redirected to login and never
loaded (the controller comment flagged this as a follow-up).

Moved it to GET/POST /api/v2/admin/preview-deploys (API\PreviewDeployController,
Administrator-gated, GitHub PAT stays server-side) driven by
client/app/pages/admin/preview-deploy.vue + a nav link. Deleted the Blade
controller, view and web routes; dropped them from ApiOnlyRouteSurfaceTest.
Also removes two more routes from the non-API web surface.

Tests: APIv2PreviewDeployTest (faked GitHub calls: admin-gates, PR list, missing
PAT, workflow dispatch, validation). vitest 882, phpunit + route-surface green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… redirect)

The preview (and prod) image only ran Laravel, so post-cutover the catch-all
redirected / to FRONTEND_URL — which defaults to http://localhost:3000 and was
unset in the fly configs — and the SPA was never built or served anywhere.

Make the deploy image serve the Nuxt SPA and reach Laravel only at /api:
- Dockerfile.fly: builder runs `nuxt build` in client/ (standalone .output);
  final image gets Node 18 to run the Nitro server.
- supervisord-fly.conf: run `node client/.output/server/index.mjs` on :3000.
- nginx-fly.conf: /api + the retained server-rendered surface (auth/bridge,
  discourse, export, calendar, outbound, {group,admin,party}/stats) -> php-fpm;
  everything else -> the Nitro server via `try_files $uri @nuxt`. /images falls
  back to Nitro so SPA and widget images coexist. The emailed-link redirectors
  drop away — the SPA owns those exact paths now.
- fly.{pr,,dev}.toml: set FRONTEND_URL + NUXT_PUBLIC_API_BASE to each app's own
  origin.

Touches only the Fly deploy path (compose/CI use docker/nginx.conf + the client
container), so CI is unaffected; validate on a preview deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPQW8msJ8eya5XYsqk9B2S
The client build ran `nuxt prepare` under Node 18, but nuxi's @clack/core
imports styleText from node:util, added in Node 20.12; client engines are
>=22. Match client/Dockerfile (node:22) and CI (cimg/node:22.16). The root
Vite 4 build is Node-22-compatible, so the shared builder bump is safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPQW8msJ8eya5XYsqk9B2S
…ew migrate)

The preview restore surfaced a real migrate failure that would also break the
prod deploy: production has the personal_access_tokens table (created
out-of-band) with no matching migrations row, so Sanctum's unguarded vendor
migration re-ran on the restored copy and failed with
"SQLSTATE[42S01] 1050 Table 'personal_access_tokens' already exists".

Disable the vendor migration (Sanctum::ignoreMigrations) and ship our own copy
under the same name, guarded with Schema::hasTable so it is a no-op when the
table already exists but still creates it on fresh installs (CI, local,
phpunit). Adds a regression test asserting up() is idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPQW8msJ8eya5XYsqk9B2S
edwh and others added 12 commits August 19, 2026 11:10
Follow-up to 983bb23: the brand colours now live in _variables.scss
($brand-orange-official #F18F01, $brand-red #F45B69, and the
$map-cluster-*/$map-pin-* role tokens built on them) and GroupMap.vue
@uses them. The navbar notification badge, which already hardcoded the
same #f45b69, now reads $brand-red too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Same change as PR 887's branch, via the client's own
$font-family-sans-serif token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
User feedback: with scrollWheelZoom off, the wheel scrolled the page
instead of zooming the map, and people couldn't work out how to zoom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
df40a22 deleted the /group/nearby list page but left its three
now-unreferenced keys (nearest_groups, nearest_groups_change,
no_groups_nearest_with_location) in en/fr/fr-BE - translations:check
counts unused keys as errors and Translations\CheckTest requires exit
0, so CI's build job has been red since. Client locale exports
regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
The "upcoming" fixtures hardcoded 2026-08-20T10:00Z, which stopped
being the future mid-morning today - 15 tests flipped red with no code
change. Upcoming/later fixtures now derive from Date.now() (+30/+31
days), and the header test derives its expected day-of-month from the
fixture in the event's timezone instead of hardcoding "20".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Mirror of the same feature on PR 887's branch, in this stack:

- GET /api/v2/users/me/profile now carries the user's geocoded lat/lng
  (floats, null when unset) - the client had no numeric coordinates
  for the signed-in user at all.
- useGroupMapGeometry gains haversineKm (real km, unlike the ordering
  approximations - this number is shown to the user).
- GroupMap emits `searched` with the landed geocode centre.
- /group/map anchors to the searched place, else the profile
  coordinates, else hides the column; rows carry distance straight
  from the names index.
- GroupsTable: opt-in distance column (optionalColumns.distance) with
  a "km" header, 887's rounding (tenths under 5 km), click-sortable
  with unplaceable groups pinned last, and a new initialSortKey prop
  so the map page opens nearest-first.

TDD (12 new tests watched fail first); client suite green (1577),
APIv2UserProfileTest green (29).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Same fix as PR 887's branch, on the v3 promise-based geocoder API:
q=Haringey never surfaces the borough in Photon's default ranking, so
the search runs a place-layer-filtered query alongside the general one
and lists places first (deduped, capped at 10, failure on either side
degrades to the other).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Only index.spec.js uses the second future date pair; no-unused-vars
failed the build-client Lint step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
The e2e-client CI job has been dying on CircleCI's 10-minute no-output
rule: several tests went stale against deliberate UI changes and each
burned 3 x 360s in silence. Verified locally per suite:

- brands round-trip: brand delete is deliberately absent (b17158c -
  develop has no brand delete anywhere); the test now pins the
  button's absence instead of waiting for it.
- global tags round-trip: delete moved into the edit form
  (638e285); final assertion counts the row rather than reading a
  table that is absent mid-refetch.
- device tests: the devices panel and its tables render twice
  (desktop + mobile collapsibles), so bare testids trip strict mode -
  scoped to the desktop panel in device.test.js and utils.addDevice;
  category/status/spare-parts became DeviceForm's plain-element
  multiselects, so the helper clicks control + option instead of
  selectOption, and the category assertion reads data-value.
- networks Add-groups: the item sits in the Actions dropdown - open it
  first instead of clicking an invisible menuitem forever.

Also a real regression this exposed: /group/all?network=N (linked from
the networks page Actions menu) redirected to /group/map WITHOUT the
query. The redirects now preserve it and /group/map applies ?network=
as the map's network filter.

Still genuinely failing (pre-existing, own session needed): the
grouptags setGroupTagAssignment cluster (a newly created network tag
never appears in the group form's tag checkboxes) and the Manager
network-logo-manage control.

Client suite green (1584); lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
e2e-client fails on "Invite volunteers modal opens from the event view
page": getByTestId('event-view-invite') finds nothing.  Nothing renders
that testid - it appears only in the test.  The file's own header
described a UI that isn't there, saying the EVENT ACTIONS dropdown was
gone and a single invite button had replaced it.

EventActionsDropdown.vue is very much still there, with the invite item
under data-testid=event-actions-invite, and it deliberately mirrors
EventActions.vue's gating of isAttending && upcoming && approved rather
than the canedit the comment claimed.  Open the dropdown and click the
item that exists.

No join step is needed despite the isAttending gate, and the corrected
comment now says why: createEventv2 writes an EventsUsers row for the
creator with role HOST, so whoever made the event is already attending
it.  An RSVP step would have failed, since the RSVP item only renders
when !isAttending.

Every testid the test now uses was checked against the components:
event-actions-dropdown, event-actions-invite, event-invite-modal,
event-invite-emails, event-invite-submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same fault as the invite test: "Host can request reviews on a finished
event" and "Following the hosting group from the event page joins it"
wait on event-view-request-review and event-view-follow-group, and
nothing renders either.  Both live in EventActionsDropdown.vue as
event-actions-request-review and event-actions-follow-group, so open the
dropdown and use those.

The gating matches what each test already sets up: request-review sits
under v-if="finished" in the canedit branch, and the test approves a past
event as admin; follow-group is v-if="hasGroup && !inGroup", and the test
switches to a host who isn't in the group.

Every testid event.test.js uses is now present in client/app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
edwh and others added 15 commits September 2, 2026 15:01
Eight grouptags tests failed looking for `.form-check` checkboxes inside
group-form-tags.  GroupForm passes tagOptions to GroupMultiSelect, not to
a checkbox group: selected tags are chips, and unselected ones only exist
in a dropdown rendered while the search box has focus.

Two things were needed, not one.  Selecting through the dropdown got the
chip added but the tests still timed out waiting for the PATCH, because
select() clears the search box without closing the dropdown - and the
dropdown is absolutely positioned over the rest of the form, including
the submit button underneath it.  Blurring the input closes it, which is
the component's own @blur behaviour.

Verified locally: the two "add and then remove a tag" tests now pass in
~14s each, having previously hit the 6 minute test timeout, and six of
the eight are green.  The two Admin-variant failures that remain are a
different fault, still being looked at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/group/view/[id] reads `group.is_member ?? isMember(id)`, and
GET /api/v2/groups/{id} always sends is_member for a logged-in caller, so
a defined `false` wins and the store's optimistic memberIds flip never
reaches the page.  Joining or leaving from the group view left the
Join/Leave item unchanged until a reload.

join() and leave() now mirror the flip onto the loaded group and undo it
on the same paths that revert memberIds.

The e2e test caught this but was looking for GroupJoinButton's
group-join-{id}/group-leave-{id}, which the view page never renders - it
uses GroupActions, whose join and leave are dropdown items with fixed
testids, so the menu has to be reopened to see the change.

Also corrects the comment above fetchCurrent, which said the response has
no is_member flag and contradicted the class doc a few lines above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pages/networks/[id].vue and pages/networks/[id]/edit.vue together make
the former the parent of the latter's route, and [id].vue has no
<NuxtPage/>, so the child never rendered: /networks/{id}/edit served the
network view page instead.  The edit screen, and the logo uploader it
owns, could not be reached at all.

Moving the parent to [id]/index.vue gives the two pages sibling routes,
which is what they are.

The test looked for network-logo-manage - which nothing renders - on
/networks/{id}, where there is no upload control even when the page
works.  It now opens the edit page and checks the uploader is there and
that the forbidden alert is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving pages/networks/[id].vue into [id]/index.vue to unnest the edit
route left show.spec.js importing a file that no longer exists, so the
whole spec file failed to load and build-client went red - 1568 tests
passed and one file never ran.

Also fixes the path in a comment in group/view/[id].vue that refers to
the same page.

Verified: vitest 154 files / 1584 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	resources/js/app.js
#	resources/js/constants.js
#	resources/js/mixins/map.js
#	resources/views/layouts/header.blade.php
#	resources/views/layouts/header_plain.blade.php
resources/js died at the Nuxt cutover and jest no longer runs on this
branch, so the test merged in from develop covers a helper in a file this
branch deletes.  The client has its own map constants.
#902 keyed the tile URL on the Laravel side, but the client carries its
own copy in utils/mapConstants.js - annotated "no API key required",
which stopped being true - so every map in the SPA would have come back
watermarked once this branch lands.  GroupMap, EventVenueMap and
GroupForm all read it.

useLeafletTiles() appends the key from runtimeConfig.public and returns
the bare URL without one, so an unconfigured environment degrades to the
watermark rather than a broken map.  Runtime config rather than a
compiled-in constant for the same reason as the Laravel side: the image
is built long before the deployed environment's secrets exist, and
Dockerfile.fly already relies on this for apiBase ("runtime-overridable
... so no API URL is baked").

The key falls back to CARTO_API_KEY, which the machine already carries as
a Fly secret and Nitro shares the environment with, so no second secret
is needed anywhere - production, previews or FLY_PREVIEW_SECRETS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I put the useLeafletTiles import and its call in the middle of the import
block, so every import after it tripped import/first and Lint failed with
nine errors.  Import alongside the other composables, call it once the
imports are done.

eslint: 0 errors. vitest: 155 files / 1588 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image upload test drove .uppy-Dashboard-input, but GroupForm renders
<TusImageUpload compact> and the compact path skips @uppy/dashboard
entirely, so no such input exists - it has its own hidden file input.

The tag filter test wants network-show-tag-filter, which nothing renders
and legacy never had.  The filter bar it needs is real but mounted
nowhere: /group/all is now a redirect to /group/map, and that passes
neither show-filters nor show-tags.  pages/group/all.vue records the gap
itself - the full-list filter bar is 887 functionality still to be ported
- so skip it with the trail written down rather than build UX over
someone's deliberate defaults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/group/map shows administrators and NCs a groups-requiring-moderation
queue above the map, and that is a second GroupsTable.  Its rows are
deliberately untouched by the map's search box, but the test matched
group-row-link- across the whole page, so it demanded moderation rows
contain the search term too and failed on the first one.

The search was working all along: typing narrowed the list from 7 rows to
4, of which the one in the map's list was the match and the other three
were moderation rows.

Scope to the map's list, which renders after the moderation panel and is
the only table when the viewer isn't a moderator, and assert the filtered
list isn't empty so a silently empty list can't pass the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
curl followed whatever it was given, so a redirect could have downgraded
the Node install script to plain HTTP.  --proto '=https' --tlsv1.2 pins
it.  Flagged by SonarCloud on both the builder and runtime stages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarCloud reliability findings, all genuine:

- The four share-stats iframes had no title, so a screen reader announced
  them as unlabelled frames.  Titled from the headings already above them.
- font-family: Asap and 'Patua One' had no generic fallback.  Asap is
  already $font-family-third ('Asap', sans-serif) and _footer.scss
  already pairs Patua One with serif, so both now match the rest.
- .nav-wrapper .dropdown-menu declared border twice; the first was dead.
- fillCentredText took an x it overwrote on its first line.  Removed the
  parameter rather than the reassignment, and updated its six callers.

eslint 0 errors, vitest 155 files / 1588 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarCloud reported 56 inputs without a label.  37 were false positives -
BFormGroup label-for renders a real <label for>, which its HTML analyser
cannot see through - and are marked as such.  These 19 were real.

Three kinds:
- Visible labels that were never associated: GroupNetworkData's per-field
  BFormGroup and its new-field <label>, and NetworkTagsManager's edit
  modal.  Given label-for/for and matching ids rather than an aria-label,
  so clicking the label focuses the field.
- Placeholder-only search and filter boxes (groups table name/location/
  country/network, the map's search, the new-tag fields).  A placeholder
  is not an accessible name - it is announced inconsistently and vanishes
  on input - so they carry an aria-label with the same wording.
- Read-only calendar URLs and the mobile date input, named from the
  heading each sits under.

The attendance stepper's heading sits above it rather than being tied to
the number field, so it takes a label prop, passed the same string the
heading shows.

eslint 0 errors, vitest 155 files / 1588 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine sortable table headers, collapsible section titles and gallery
thumbnails carried role="button" and a click handler but no tabindex and
no key handler, so a keyboard or screen-reader user could not reach or
activate them - they announced as buttons and then did nothing.

Each now takes focus and responds to Enter and Space, matching what the
role already promised.  Only elements that declare role="button" are
touched; real <button>/<BButton> elements are left alone, since they
handle both keys natively and would otherwise fire twice.

eslint 0 errors, vitest 155 files / 1588 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groups keep ending up with the same repair event twice - a second host
adds one not knowing the first did, or someone re-submits after fixing a
typo instead of editing what is there.

Before a create, the form asks the API what the group already has around
that date and scores each against the one being posted:

  certain   same place, same instant
  likely    same place, same day, within an hour
            same name, same day, whatever the time
            same name, saved by this group in the last 15 minutes - the
            double submit, where the first post landed unseen
  possible  same place, same day at another time
            same instant somewhere else

An online event and one in a room are never compared: whatever else
lines up they are not the same event.  "Same day" is the day in the
event's own timezone, so an evening in Auckland and the small hours after
it are correctly different days.

Anything found is shown in a modal with its date, time and place, and
three ways out: edit that event, post anyway because it really is
different, or go back.  Advisory throughout - these are heuristics over
text and times, so it never blocks, and a lookup that fails is swallowed
rather than stopping someone posting.

Strings added for en, fr and fr-BE.

TDD: criteria and modal specs written first (23 cases), then the five
covering the form - shows instead of posting, posts on "post anyway",
stays put on cancel, posts when nothing matches, and posts when the check
itself errors.  eslint 0 errors, vitest 157 files / 1616 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

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

Labels

preview Deploy a Fly.io preview for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants