fix(profiles): restore admin ability to edit other users' profiles

The update_profile endpoint used RequireUserOwner which returns only the
caller's user_id — no role information. The ownership check blocked ALL
non-owner edits with 403, including admins. This regressed in d37b30a
when the old Member model (RequireAdmin) was replaced with UserProfile.

Backend: read X-User-Roles header directly in update_profile (same
pattern as node_tags.py / routes.py) and bypass the ownership check when
the admin role is present. Regular members editing their own profiles
are unaffected — RequireUserOwner stays as the dependency.

Frontend: extract ProfileEditForm component (with data-testids) from
OwnProfileView. PublicProfileView now shows an inline edit form when an
admin views another user's profile. Owner still gets the existing edit
link; non-admins see nothing.

Tests:
- Backend: test_update_profile_admin_can_edit_other (admin edits other
  user's profile, asserts 200 + all fields updated)
- Vitest: 4 new tests — admin button visibility, non-admin hidden,
  owner link vs admin button, form submission to correct endpoint
- E2E: admin-profile-edit.spec.ts (admin edits Mem South's profile,
  verifies persistence; admin on own profile sees no admin button);
  members.spec.ts negative assertion (member sees no admin button)
This commit is contained in:
Louis King
2026-07-24 22:41:32 +01:00
parent 140bd437ed
commit 86c8079fb1
6 changed files with 400 additions and 126 deletions
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
import { ADMIN_STATE } from "../utils/helpers";
test.use({ storageState: ADMIN_STATE });
test.describe.serial("admin profile edit", () => {
test("admin can edit another user's profile", async ({ page }) => {
await page.goto("/members");
// Navigate to Mem South's profile (not the admin's own).
await page
.getByTestId("member-card")
.filter({ hasText: "Mem South" })
.first()
.click();
await expect(page).toHaveURL(/\/profile\//);
// Admin sees the edit button (owner does NOT — sub mismatch).
await expect(page.getByTestId("profile-admin-edit")).toBeVisible();
// Click edit — form appears pre-filled with the target profile's values.
await page.getByTestId("profile-admin-edit").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("profile-name")).toHaveValue("Mem South");
// Edit callsign and save.
await page.getByTestId("profile-callsign").fill("ADMEDIT");
await page.getByTestId("profile-save").click();
// Read-only view returns with updated callsign badge.
await expect(page.getByTestId("profile-admin-edit")).toBeVisible();
await expect(page.getByText("ADMEDIT")).toBeVisible();
});
test("admin does not see admin edit button on own profile", async ({
page,
}) => {
// Navigate to own profile via members page.
await page.goto("/members");
await page
.getByTestId("member-card")
.filter({ hasText: "PW Admin" })
.first()
.click();
await expect(page).toHaveURL(/\/profile\//);
// Admin IS the owner here — should see the owner edit link, not the
// admin edit button.
await expect(page.getByTestId("profile-admin-edit")).toHaveCount(0);
});
});
+4
View File
@@ -32,6 +32,10 @@ test.describe("members", () => {
await expect(page).toHaveURL(/\/profile\/[0-9a-f-]{36}/);
await expect(page.getByText("Mem South").first()).toBeVisible();
await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
// A member viewing another user's profile must NOT see the admin edit
// button.
await expect(page.getByTestId("profile-admin-edit")).toHaveCount(0);
});
test("clicking a member node shows the node detail page", async ({ page }) => {