Skip to content

Migrate profile-edit page to Vue + /api/v2/users/me (Group 2.2, all 5 tabs)#868

Open
edwh wants to merge 18 commits into
developfrom
RES-PROFILE-EDIT-vue
Open

Migrate profile-edit page to Vue + /api/v2/users/me (Group 2.2, all 5 tabs)#868
edwh wants to merge 18 commits into
developfrom
RES-PROFILE-EDIT-vue

Conversation

@edwh

@edwh edwh commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates the entire profile-edit page (Group 2.2 of the Blade-to-Vue plan) from Blade to Vue components backed by /api/v2/users/me/* endpoints. All five tabs are now Vue:

  • email-preferencesEmailPreferencesTab.vue + GET/PATCH /api/v2/users/me/preferences
  • account / languageLanguageTab.vue + PATCH /api/v2/users/me/language
  • calendarsCalendarsTab.vue + GET /api/v2/users/me/calendars
  • repair-directoryRepairDirectoryTab.vue + GET/PATCH /api/v2/users/me/repair-directory-*
  • profile (name/country/email/town/age/gender/biography + repair skills) → ProfileInfoTab.vue + SkillsTab.vue + GET/PATCH /api/v2/users/me/profile and /skills

Each endpoint has OpenAPI annotations and a dedicated APIv2*Test. Responses return an explicit field whitelist (no api_token / calendar_hash / lat-lng leakage; User::$hidden asserted in tests).

Deferred (needs security review)

The profile photo upload form is intentionally left as legacy Blade/jQuery, pending an adversarial review of the upload surface (file handling, validation, storage). It is clearly commented in profile.blade.php.

Behavioural note

The migrated profile/language tabs operate on Auth::user() ("me") only — the legacy /profile/edit-info / /profile/edit-tags web routes still exist and still support an admin editing another user's id, but there is no longer UI for that on this page (consistent precedent across the migrated tabs). Flag if admin-edits-another-user's-profile is a required surface.

Test plan

  • APIv2UserProfileTest (15), APIv2UserPreferencesTest, APIv2UserCalendarsTest, APIv2UserLanguageTest, APIv2RepairDirectoryTest — full tab set green locally (35 tests)
  • EditProfileTest, ProfileTest, PrivilegeEscalationTest pass (skills assertion updated to check persisted UsersSkills rows rather than server-rendered HTML)
  • vite build succeeds
  • CircleCI green

…Group 2.2 partial 1/5)

The profile-edit page is a tabbed shell with 5 partials (profile, account,
email-preferences, calendars, repair-directory). This commit migrates the
email-preferences partial — the simplest — as the first slice.

- GET/PATCH /api/v2/users/me/preferences (auth:api, OpenAPI annotated)
  returning {data: {invites: bool}}
- tests/Feature/Users/APIv2UserPreferencesTest: 7 cases covering auth,
  shape, validation, and persistence in both directions
- resources/js/components/EmailPreferencesTab.vue: small Vue component
  rendering the existing UI and POSTing via the new endpoint
- resources/views/user/profile/email-preferences.blade.php now mounts
  the Vue component; the existing /profile/edit-preferences POST handler
  is unchanged and remains a fallback
- lang/{en,fr,fr-BE}/general.php: error_occurred key for the toast

Follow-ups: migrate profile/account/calendars/repair-directory tabs
incrementally on the same branch.
edwh added 2 commits May 30, 2026 23:29
…oup 2.2 2/5)

- GET /api/v2/users/me/calendars (auth:api) returns user + per-group +
  admin all-events + group-area calendar URLs with OpenAPI
- tests/Feature/Users/APIv2UserCalendarsTest: 3 cases (auth, host shape,
  admin flag/url)
- CalendarsTab.vue: lists subscription URLs, copy buttons, area select;
  uses clipboard API instead of the old jQuery btn-copy-input-text
- calendars.blade.php now mounts the Vue component
…ir-directory-* (Group 2.2 3/5)

- GET /api/v2/users/{id}/repair-directory-options: per-option `selected`
  + `disabled` flags computed against existing UserPolicy::changeRepairDirRole.
- PATCH /api/v2/users/{id}/repair-directory-role: policy-gated save.
- APIv2RepairDirectoryTest: 7 cases (auth/404/super-admin enabled/non-admin
  disabled/forbidden update/persistence/validation enum).
- RepairDirectoryTab.vue: replicates the existing select with disabled
  options and submit button.
@edwh edwh marked this pull request as ready for review May 30, 2026 22:34
edwh and others added 4 commits May 30, 2026 23:35
…up 2.2 4/5 partial)

The account tab contains 4 sub-forms (password, language, admin matrix,
soft-delete). This commit migrates the language sub-form only —
non-sensitive and isolated. Password change, admin matrix, and
soft-delete remain on existing CSRF-protected POST routes pending
adversarial review of those security boundaries.

- GET /api/v2/users/me/language returns current language + supported locales
- PATCH /api/v2/users/me/language validates against supported list, fires
  UserLanguageUpdated event, persists to user
- APIv2UserLanguageTest: 5 cases (get auth/shape, update auth/validation/persist)
- LanguageTab.vue: select + save, reloads after save to apply translations
…ed key

The PR's new v2 endpoint tests exposed three real issues:

1. Unauthenticated auth:api requests returned 500 instead of 401. The custom
   JSON exception handler mapped any exception without getStatusCode() to 500,
   and Laravel's AuthenticationException has none. Added an explicit
   AuthenticationException -> 401 branch. This is correct API behaviour and was
   simply never asserted before (these are the suite's first 401 tests), so it
   fixed every *RequiresAuth test across preferences/calendars/language/
   repair-directory at once.

2. getMyCalendarsv2 read the admin all-events hash via env('CALENDAR_HASH')
   directly, which returns null under config:cache in production (a latent bug)
   and can't be overridden in tests. Moved it to config('restarters.calendar_hash')
   and updated the test to set it via config() instead of putenv() (which
   Laravel's env() does not read).

3. email_alerts_pref1 ('monthly newsletter') was only ever referenced inside a
   commented-out block in the old blade, so translations:check passed on the
   stray string. Migrating that tab to Vue removed the last textual reference,
   orphaning the key. The newsletter checkbox has long been disabled (handled by
   the community platform), so removed the dead key from en/fr/fr-BE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PHPUnit is green after the previous commit; the only failure was
tests/Integration/event.test.js "Invite volunteers modal opens from Event
Actions dropdown" timing out clicking the dropdown item. That test is
untouched by this PR, the branch is level with develop, and the test has a
long history of timeout/hang flake-fixes (Google Maps hang in Docker noted in
its own comments). Empty commit to re-run the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event Playwright suite failed deterministically today (last day of May)
on "Invite volunteers modal opens from Event Actions dropdown". Root cause is
in the shared createEvent() helper, not the test or this PR's code:

createEvent(past=false) selected the last day of the *current* month at
13:00-14:00. On the last day of a month that date is "today"; when CI runs
after 14:00 local the event has already finished, so the API reports
upcoming=false and EventActions correctly hides the "Invite Volunteers" item
(gated on isAttending && upcoming && approved). The test then times out
waiting for an item that will never appear. develop passed only because its
run happened earlier in the day.

Fix: for future events, advance to the next month before selecting the day
(mirroring the past branch's "Previous month"), so the event is always
comfortably upcoming regardless of when the suite runs. Only the two
past=false callers in event.test.js are affected; device.test.js uses past.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

edwh added 2 commits July 3, 2026 16:08
Migrates the name/country/email/townCity/age/gender/biography form and the
repair-skills selector to Vue, backed by new /api/v2/users/me/profile and
/api/v2/users/me/skills endpoints (mirroring the auth:api + Auth::user()
pattern used by the language/calendars/preferences endpoints).

- getMyProfilev2 / updateMyProfilev2: replicate postProfileInfoEdit's
  validation and geocoding behaviour (geocode townCity+country, clear
  lat/long on empty location or geocode failure).
- getMySkillsv2 / updateMySkillsv2: replicate postProfileTagsEdit's
  skillsold() sync and host-role promotion when a category-1 skill is
  selected.
- ProfileInfoTab.vue / SkillsTab.vue: new components mounted from
  profile.blade.php, replacing the edit-info and edit-tags forms.
- APIv2UserProfileTest: 15 cases covering auth, validation, persistence,
  geocoding (success/empty/failure), skills replace, and PII hiding
  (api_token/calendar_hash/latitude/longitude absent from responses).
- EditProfileTest::test_tags_update updated to assert persisted skill IDs
  directly rather than scraping a server-rendered <option selected>, since
  the skills selector is now populated client-side via the API.

The "change photo" upload form is intentionally left as legacy Blade,
pending a security review of the upload surface.
@edwh edwh changed the title Migrate profile email-preferences tab to Vue + /api/v2/users/me/preferences (Group 2.2 1/5) Migrate profile-edit page to Vue + /api/v2/users/me (Group 2.2, all 5 tabs) Jul 3, 2026
edwh added 2 commits July 3, 2026 18:42
The profile tab migration replaced profile.blade.php (which was the only user of
`@lang('general.other_profile')`) with the ProfileInfoTab/SkillsTab Vue mounts,
leaving the key unused. `php artisan translations:check` fails on unused keys, so
remove `other_profile` from every locale's general.php.

Verified: translations:check exits 0.
SonarCloud flagged the area <select> and the read-only area-URL <input> in
CalendarsTab.vue as form controls with no associated label. Add ids and
visually-hidden (sr-only) <label> elements so each control is properly labelled
for assistive tech.
edwh added a commit that referenced this pull request Jul 3, 2026
…sified

Mark Group 2.2 (profile-edit, all 5 tabs) done via #868, note the four PRs are
rebased onto current develop and CI-green, record 3.2 dead-code removal (#891),
and flag that the only remaining items are security-sensitive (group/view
permission flags; profile-photo upload; account password/admin/delete) or
deliberately server-rendered iframe widgets.
edwh added 7 commits July 3, 2026 22:04
…min)

Completes the profile-edit page migration. These three surfaces are
security-sensitive; each API endpoint replicates the existing web handler's
authorization exactly:

- Change password: PATCH /api/v2/users/me/password. Operates only on
  Auth::user() (no id param — cannot target another user); requires
  Hash::check(current_password); enforces new === confirmation; resets the
  recovery token and fires PasswordChanged. Response returns only {success}.
- Admin settings: GET/PATCH /api/v2/users/{id}/admin-settings. Administrator
  only — abort(403) for any non-admin (matches postAdminEdit). role set
  directly (not mass-assignable); NetworkCoordinator demotion detaches
  networks; soft-deleted group memberships restored before sync of
  groups/preferences/permissions.
- Profile photo: POST /api/v2/users/me/photo. Auth::user() only; validates
  required|image|mimes:jpeg,png,gif,webp|max:2048; stores via the existing
  FixometerFile helper.

Also: Handler::render() now maps AuthorizationException -> 403 (it lacks
getStatusCode() and previously fell through to 500), so authorize() failures
return 403 for JSON API clients.

New Vue: PasswordTab, AdminSettingsTab (admin-only UI), ProfilePhotoTab.
Tests: APIv2UserPasswordTest (6), APIv2UserAdminSettingsTest (11),
APIv2UserPhotoTest (6) — cover 401/403/422/200, the non-admin privilege-
escalation guard, wrong-current-password, non-image upload, and PII hiding.

NOTE: these are sensitive surfaces (credentials, role assignment, file upload)
and warrant a security review pass.
FixometerFile::upload() only recognises jpeg/png/gif (finfo mime map), so a webp
that passed the API's mimes:...,webp validation would then be silently rejected
at storage. Align the contract: remove webp from the validation rule and the
file input's accept attribute.
The test scraped the CSRF token from the admin-settings <form> on /user/edit and
asserted a server-rendered <option selected> for the re-added group. That form is
now the AdminSettingsTab Vue component (PATCH /api/v2/users/{id}/admin-settings),
so both the token input and the option markup are gone. Re-add the host via the
API endpoint and assert membership via the groups() relationship instead of
scraping HTML.
Add DELETE /api/v2/users/me (auth:api) mirroring postSoftDeleteUser:
authorizes via UserPolicy::delete then soft-deletes Auth::user() (existing
event handlers anonymise the record). Replace the last server-rendered
form on the account page with DeleteAccountTab.vue - a danger alert plus
a confirm modal that calls the new endpoint and redirects to /login.

Add APIv2UserDeleteAccountTest covering unauthenticated access, a
successful self-delete (soft-deleted + trashed), and that the id cannot
be smuggled in to delete another user's account.
…server

Replace the multipart POST /api/v2/users/me/photo transport with the tus
protocol: Uppy (core + dashboard + tus + compressor) uploads to a new
/api/tus route backed by ankitpokhrel/tus-php (TusController wraps
TusPhp\Tus\Server, storage under storage/app/tus + storage/app/tus-cache,
both gitignored). The tus route is deliberately outside auth:api (tus's
chunked PATCH requests can't carry an API token cleanly) and CSRF-exempt,
capped at 10MB per upload as a basic anti-abuse limit.

updateMyPhotov2 now takes an upload_key instead of a file: it looks the
key up in the shared tus cache (App\Helpers\Tus::buildCache(), kept
separate from buildServer() because tus-php's Server eagerly builds a
Request from PHP's real superglobals - fatal under this worktree's
elevated CLI error_reporting), confirms the upload is complete and
under 2MB, sniffs the real MIME type, then hands the assembled file to
a new FixometerFile::uploadLocalFile() (factored out of upload() so the
same thumbnailing/DB-record pipeline runs without requiring a
move_uploaded_file()-eligible source). The tus temp file and cache entry
are deleted after every attempt, successful or not, so a key can't be
replayed.

ProfilePhotoTab.vue is rewritten around a plain-JS Uppy Dashboard
(Vue 2 has no supported @uppy/vue release - every version requires
Vue 3 - so the framework-agnostic core/dashboard/tus/compressor
packages are mounted directly via a template ref instead). On upload
completion it derives the tus key from the last path segment of
file.tus.uploadUrl, same convention as Freegle's OurUploader.vue, and
posts it to /photo.

Tests: APIv2UserPhotoTest is rewritten around the upload_key contract,
seeding completed uploads directly into the real tus cache/upload dir
(same factory the controller uses) so updateMyPhotov2 itself runs
unmocked. APIv2TusUploadTest drives the real /api/tus route's creation
step through the actual HTTP test client (with PHP's real $_SERVER
superglobals seeded, since tus-php reads those directly rather than
Laravel's request object) - the PATCH byte-transfer step can't be
driven the same way because tus-php hardcodes php://input as its
source, which isn't reachable from a single PHPUnit process without a
real HTTP server in front of it.
…ration

Like testAdminRemoveReaddHost, this scraped input[name=_token] from /user/edit and
POSTed to /profile/edit-admin-settings. The admin-settings form is now the
AdminSettingsTab Vue component; once the account soft-delete form was also removed
the page had no _token input at all, so $tokens[0] was undefined. Demote the
coordinator via PATCH /api/v2/users/{id}/admin-settings instead.
The tus endpoint is registered in routes/api.php, which uses the "api" middleware
group — that group does not include VerifyCsrfToken at all, so the api/tus entries
in VerifyCsrfToken::$except were redundant. Removing them restores $except to empty
(no behaviour change) and clears SonarCloud's S4502 "disabling CSRF protection"
finding. Comment updated to explain why CSRF simply doesn't apply here.
@sonarqubecloud

sonarqubecloud Bot commented Jul 4, 2026

Copy link
Copy Markdown

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.

1 participant