Skip to content

Commit e0c1a67

Browse files
Merge pull request #338 from ipnet-mesh/fix/admin-profile-edit
fix(profiles): restore admin ability to edit other users' profiles
2 parents 140bd43 + 86c8079 commit e0c1a67

6 files changed

Lines changed: 400 additions & 126 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect, test } from "@playwright/test";
2+
import { ADMIN_STATE } from "../utils/helpers";
3+
4+
test.use({ storageState: ADMIN_STATE });
5+
6+
test.describe.serial("admin profile edit", () => {
7+
test("admin can edit another user's profile", async ({ page }) => {
8+
await page.goto("/members");
9+
10+
// Navigate to Mem South's profile (not the admin's own).
11+
await page
12+
.getByTestId("member-card")
13+
.filter({ hasText: "Mem South" })
14+
.first()
15+
.click();
16+
await expect(page).toHaveURL(/\/profile\//);
17+
18+
// Admin sees the edit button (owner does NOT — sub mismatch).
19+
await expect(page.getByTestId("profile-admin-edit")).toBeVisible();
20+
21+
// Click edit — form appears pre-filled with the target profile's values.
22+
await page.getByTestId("profile-admin-edit").click();
23+
await expect(page.getByTestId("profile-form")).toBeVisible();
24+
await expect(page.getByTestId("profile-name")).toHaveValue("Mem South");
25+
26+
// Edit callsign and save.
27+
await page.getByTestId("profile-callsign").fill("ADMEDIT");
28+
await page.getByTestId("profile-save").click();
29+
30+
// Read-only view returns with updated callsign badge.
31+
await expect(page.getByTestId("profile-admin-edit")).toBeVisible();
32+
await expect(page.getByText("ADMEDIT")).toBeVisible();
33+
});
34+
35+
test("admin does not see admin edit button on own profile", async ({
36+
page,
37+
}) => {
38+
// Navigate to own profile via members page.
39+
await page.goto("/members");
40+
await page
41+
.getByTestId("member-card")
42+
.filter({ hasText: "PW Admin" })
43+
.first()
44+
.click();
45+
await expect(page).toHaveURL(/\/profile\//);
46+
47+
// Admin IS the owner here — should see the owner edit link, not the
48+
// admin edit button.
49+
await expect(page.getByTestId("profile-admin-edit")).toHaveCount(0);
50+
});
51+
});

e2e/tests/members.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ test.describe("members", () => {
3232
await expect(page).toHaveURL(/\/profile\/[0-9a-f-]{36}/);
3333
await expect(page.getByText("Mem South").first()).toBeVisible();
3434
await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
35+
36+
// A member viewing another user's profile must NOT see the admin edit
37+
// button.
38+
await expect(page.getByTestId("profile-admin-edit")).toHaveCount(0);
3539
});
3640

3741
test("clicking a member node shows the node detail page", async ({ page }) => {

src/meshcore_hub/api/routes/user_profiles.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from sqlalchemy import func, or_, select
88
from sqlalchemy.orm import selectinload
99

10-
from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER
10+
from meshcore_hub.api.auth import (
11+
RequireRead,
12+
RequireUserOwner,
13+
X_USER_ID_HEADER,
14+
X_USER_ROLES_HEADER,
15+
)
1116
from meshcore_hub.api.cache import cached
1217
from meshcore_hub.api.cache_invalidation import (
1318
invalidate_dashboard,
@@ -223,10 +228,14 @@ def update_profile(
223228
)
224229

225230
if profile.user_id != caller_id:
226-
raise HTTPException(
227-
status_code=status.HTTP_403_FORBIDDEN,
228-
detail="Access denied: cannot modify another user's profile",
229-
)
231+
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
232+
roles = [r.strip() for r in roles_header.split(",") if r.strip()]
233+
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
234+
if admin_role not in roles:
235+
raise HTTPException(
236+
status_code=status.HTTP_403_FORBIDDEN,
237+
detail="Access denied: cannot modify another user's profile",
238+
)
230239

231240
if profile_update.name is not None:
232241
profile.name = profile_update.name.strip()

src/meshcore_hub/web/static/js/spa-react/pages/Profile.test.tsx

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { screen, waitFor } from "@testing-library/react";
2-
import { describe, expect, it, vi } from "vitest";
1+
import { fireEvent, screen, waitFor } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
33

44
import { Profile } from "@/pages/Profile";
55
import { renderWithProviders } from "@/test/renderWithProviders";
@@ -52,6 +52,110 @@ describe("Profile (public view)", () => {
5252
});
5353
});
5454

55+
describe("Profile (admin edit)", () => {
56+
afterEach(() => {
57+
window.__APP_CONFIG__ = makeConfig();
58+
});
59+
60+
function setAdminConfig() {
61+
const config = makeConfig({
62+
oidc_enabled: true,
63+
user: { sub: "admin-user", name: "Admin" },
64+
roles: ["admin"],
65+
role_names: { admin: "admin", operator: "operator", member: "member" },
66+
});
67+
window.__APP_CONFIG__ = config;
68+
return config;
69+
}
70+
71+
function setMemberConfig() {
72+
const config = makeConfig({
73+
oidc_enabled: true,
74+
user: { sub: "other-user", name: "Member" },
75+
roles: ["member"],
76+
role_names: { admin: "admin", operator: "operator", member: "member" },
77+
});
78+
window.__APP_CONFIG__ = config;
79+
return config;
80+
}
81+
82+
it("admin sees edit button on another user's profile", async () => {
83+
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
84+
const config = setAdminConfig();
85+
renderWithProviders(<Profile />, {
86+
route: "/profile/p1",
87+
routePath: "/profile/:id",
88+
config,
89+
});
90+
await waitFor(() => {
91+
expect(screen.getByTestId("profile-admin-edit")).toBeInTheDocument();
92+
});
93+
});
94+
95+
it("non-admin does not see edit button on another user's profile", async () => {
96+
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
97+
const config = setMemberConfig();
98+
renderWithProviders(<Profile />, {
99+
route: "/profile/p1",
100+
routePath: "/profile/:id",
101+
config,
102+
});
103+
await waitFor(() => {
104+
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
105+
});
106+
expect(screen.queryByTestId("profile-admin-edit")).toBeNull();
107+
});
108+
109+
it("owner sees edit link, not admin edit button", async () => {
110+
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
111+
const config = makeConfig({
112+
oidc_enabled: true,
113+
user: { sub: "user-123", name: "Jane" },
114+
roles: ["admin"],
115+
role_names: { admin: "admin", operator: "operator", member: "member" },
116+
});
117+
window.__APP_CONFIG__ = config;
118+
renderWithProviders(<Profile />, {
119+
route: "/profile/p1",
120+
routePath: "/profile/:id",
121+
config,
122+
});
123+
await waitFor(() => {
124+
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
125+
});
126+
expect(screen.queryByTestId("profile-admin-edit")).toBeNull();
127+
});
128+
129+
it("admin edit form submits to correct endpoint", async () => {
130+
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
131+
const apiPutSpy = vi.spyOn(api, "apiPut").mockResolvedValue(undefined);
132+
const config = setAdminConfig();
133+
renderWithProviders(<Profile />, {
134+
route: "/profile/p1",
135+
routePath: "/profile/:id",
136+
config,
137+
});
138+
await waitFor(() => {
139+
expect(screen.getByTestId("profile-admin-edit")).toBeInTheDocument();
140+
});
141+
142+
fireEvent.click(screen.getByTestId("profile-admin-edit"));
143+
144+
const nameInput = await screen.findByTestId("profile-name");
145+
fireEvent.change(nameInput, { target: { value: "Admin Set Name" } });
146+
fireEvent.click(screen.getByTestId("profile-save"));
147+
148+
await waitFor(() => {
149+
expect(apiPutSpy).toHaveBeenCalledWith("/api/v1/user/profile/p1", {
150+
name: "Admin Set Name",
151+
callsign: "AB1CDE",
152+
description: "Mesh enthusiast",
153+
url: "https://example.com",
154+
});
155+
});
156+
});
157+
});
158+
55159
describe("Profile (own view)", () => {
56160
it("shows a login prompt when OIDC is disabled", async () => {
57161
renderWithProviders(<Profile />, { route: "/profile" });

0 commit comments

Comments
 (0)