Merge pull request #338 from ipnet-mesh/fix/admin-profile-edit

fix(profiles): restore admin ability to edit other users' profiles
This commit is contained in:
JingleManSweep
2026-07-24 22:45:58 +01:00
committed by GitHub
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 }) => {
+14 -5
View File
@@ -7,7 +7,12 @@ from pydantic import AnyUrl
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER
from meshcore_hub.api.auth import (
RequireRead,
RequireUserOwner,
X_USER_ID_HEADER,
X_USER_ROLES_HEADER,
)
from meshcore_hub.api.cache import cached
from meshcore_hub.api.cache_invalidation import (
invalidate_dashboard,
@@ -223,10 +228,14 @@ def update_profile(
)
if profile.user_id != caller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: cannot modify another user's profile",
)
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
roles = [r.strip() for r in roles_header.split(",") if r.strip()]
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
if admin_role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: cannot modify another user's profile",
)
if profile_update.name is not None:
profile.name = profile_update.name.strip()
@@ -1,5 +1,5 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Profile } from "@/pages/Profile";
import { renderWithProviders } from "@/test/renderWithProviders";
@@ -52,6 +52,110 @@ describe("Profile (public view)", () => {
});
});
describe("Profile (admin edit)", () => {
afterEach(() => {
window.__APP_CONFIG__ = makeConfig();
});
function setAdminConfig() {
const config = makeConfig({
oidc_enabled: true,
user: { sub: "admin-user", name: "Admin" },
roles: ["admin"],
role_names: { admin: "admin", operator: "operator", member: "member" },
});
window.__APP_CONFIG__ = config;
return config;
}
function setMemberConfig() {
const config = makeConfig({
oidc_enabled: true,
user: { sub: "other-user", name: "Member" },
roles: ["member"],
role_names: { admin: "admin", operator: "operator", member: "member" },
});
window.__APP_CONFIG__ = config;
return config;
}
it("admin sees edit button on another user's profile", async () => {
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
const config = setAdminConfig();
renderWithProviders(<Profile />, {
route: "/profile/p1",
routePath: "/profile/:id",
config,
});
await waitFor(() => {
expect(screen.getByTestId("profile-admin-edit")).toBeInTheDocument();
});
});
it("non-admin does not see edit button on another user's profile", async () => {
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
const config = setMemberConfig();
renderWithProviders(<Profile />, {
route: "/profile/p1",
routePath: "/profile/:id",
config,
});
await waitFor(() => {
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
});
expect(screen.queryByTestId("profile-admin-edit")).toBeNull();
});
it("owner sees edit link, not admin edit button", async () => {
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
const config = makeConfig({
oidc_enabled: true,
user: { sub: "user-123", name: "Jane" },
roles: ["admin"],
role_names: { admin: "admin", operator: "operator", member: "member" },
});
window.__APP_CONFIG__ = config;
renderWithProviders(<Profile />, {
route: "/profile/p1",
routePath: "/profile/:id",
config,
});
await waitFor(() => {
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
});
expect(screen.queryByTestId("profile-admin-edit")).toBeNull();
});
it("admin edit form submits to correct endpoint", async () => {
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
const apiPutSpy = vi.spyOn(api, "apiPut").mockResolvedValue(undefined);
const config = setAdminConfig();
renderWithProviders(<Profile />, {
route: "/profile/p1",
routePath: "/profile/:id",
config,
});
await waitFor(() => {
expect(screen.getByTestId("profile-admin-edit")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("profile-admin-edit"));
const nameInput = await screen.findByTestId("profile-name");
fireEvent.change(nameInput, { target: { value: "Admin Set Name" } });
fireEvent.click(screen.getByTestId("profile-save"));
await waitFor(() => {
expect(apiPutSpy).toHaveBeenCalledWith("/api/v1/user/profile/p1", {
name: "Admin Set Name",
callsign: "AB1CDE",
description: "Mesh enthusiast",
url: "https://example.com",
});
});
});
});
describe("Profile (own view)", () => {
it("shows a login prompt when OIDC is disabled", async () => {
renderWithProviders(<Profile />, { route: "/profile" });
@@ -1,8 +1,8 @@
import { type FormEvent } from "react";
import { type FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import { useAppConfig, hasRole } from "@/context/AppConfigContext";
import { apiGet, apiPut } from "@/utils/api";
import { qk, invalidate } from "@/utils/queryKeys";
import { resolveNodeName, useFormatDateTime } from "@/utils/format";
@@ -116,9 +116,120 @@ function AdoptedNodesCard({
);
}
function ProfileEditForm({
profile,
onSuccess,
onError,
}: {
profile: UserProfileData;
onSuccess?: () => void;
onError?: (msg: string) => void;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: ({
id,
body,
}: {
id: string;
body: Record<string, unknown>;
}) => apiPut(`/api/v1/user/profile/${id}`, body),
onSuccess: () => invalidate.profiles(queryClient),
});
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const body = {
name: String(data.get("name") ?? "").trim() || null,
callsign: String(data.get("callsign") ?? "").trim() || null,
description: String(data.get("description") ?? "").trim() || null,
url: String(data.get("url") ?? "").trim() || null,
};
try {
await updateMutation.mutateAsync({ id: profile.id, body });
onSuccess?.();
} catch (err) {
onError?.((err as Error).message);
}
};
return (
<form onSubmit={handleSubmit} className="py-4 space-y-4" data-testid="profile-form">
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.name_label")}
</span>
<input
type="text"
name="name"
data-testid="profile-name"
className="input flex-1"
defaultValue={profile.name || ""}
placeholder={t("user_profile.name_placeholder")}
maxLength={255}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.callsign_label")}
</span>
<input
type="text"
name="callsign"
data-testid="profile-callsign"
className="input flex-1"
defaultValue={profile.callsign || ""}
placeholder={t("user_profile.callsign_placeholder")}
maxLength={20}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.description_label")}
</span>
<input
type="text"
name="description"
data-testid="profile-description"
className="input flex-1"
defaultValue={profile.description || ""}
placeholder={t("user_profile.description_placeholder")}
maxLength={500}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.url_label")}
</span>
<input
type="url"
name="url"
data-testid="profile-url"
className="input flex-1"
defaultValue={profile.url || ""}
placeholder={t("user_profile.url_placeholder")}
maxLength={2048}
/>
</label>
<button
type="submit"
data-testid="profile-save"
className="btn btn-primary btn-sm"
>
{t("user_profile.save_profile")}
</button>
</form>
);
}
function PublicProfileView({ id }: { id: string }) {
const { t } = useTranslation();
const config = useAppConfig();
const [editMode, setEditMode] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const { data: profile, error: queryError } = useQuery({
queryKey: qk.profiles.detail(id),
queryFn: ({ signal }) =>
@@ -131,6 +242,7 @@ function PublicProfileView({ id }: { id: string }) {
const isOwner =
!!config.user && !!profile.user_id && config.user.sub === profile.user_id;
const isAdmin = hasRole("admin");
return (
<>
@@ -147,34 +259,79 @@ function PublicProfileView({ id }: { id: string }) {
{t("user_profile.edit_profile")}
</Link>
)}
{!isOwner && isAdmin && !editMode && (
<button
data-testid="profile-admin-edit"
className="btn btn-primary btn-sm"
onClick={() => {
setEditMode(true);
setEditError(null);
}}
>
{t("user_profile.edit_profile")}
</button>
)}
{editMode && (
<button
data-testid="profile-admin-cancel"
className="btn btn-ghost btn-sm"
onClick={() => {
setEditMode(false);
setEditError(null);
}}
>
{t("common.cancel")}
</button>
)}
</PageHeader>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
{profile.name || t("common.unnamed")}
{profile.callsign && <CallsignBadge callsign={profile.callsign} />}
</h2>
<RoleBadges roles={profile.roles} />
{profile.description && (
<p className="text-sm opacity-80 mt-2">{profile.description}</p>
)}
{profile.url && (
<a
href={profile.url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary text-sm mt-1 inline-block"
>
{profile.url}
</a>
)}
<MemberSince createdAt={profile.created_at} />
{hasOperatorOrAdmin(profile.roles, config) && (
<AdoptedNodesCard profile={profile} className="mt-6" />
)}
{editMode && isAdmin ? (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">{t("user_profile.edit_profile")}</h2>
<RoleBadges roles={profile.roles} />
{editError && <ErrorAlert message={editError} />}
<ProfileEditForm
profile={profile}
onSuccess={() => {
setEditMode(false);
setEditError(null);
}}
onError={(msg) => setEditError(msg)}
/>
<MemberSince createdAt={profile.created_at} />
</div>
</div>
</div>
) : (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
{profile.name || t("common.unnamed")}
{profile.callsign && (
<CallsignBadge callsign={profile.callsign} />
)}
</h2>
<RoleBadges roles={profile.roles} />
{profile.description && (
<p className="text-sm opacity-80 mt-2">{profile.description}</p>
)}
{profile.url && (
<a
href={profile.url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary text-sm mt-1 inline-block"
>
{profile.url}
</a>
)}
<MemberSince createdAt={profile.created_at} />
{hasOperatorOrAdmin(profile.roles, config) && (
<AdoptedNodesCard profile={profile} className="mt-6" />
)}
</div>
</div>
)}
</>
);
}
@@ -184,7 +341,6 @@ function OwnProfileView() {
const config = useAppConfig();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { data: profile, error: queryError } = useQuery({
queryKey: qk.profiles.me(),
queryFn: ({ signal }) =>
@@ -192,17 +348,6 @@ function OwnProfileView() {
});
const error = queryError ? queryError.message : null;
const updateMutation = useMutation({
mutationFn: ({
id,
body,
}: {
id: string;
body: Record<string, unknown>;
}) => apiPut(`/api/v1/user/profile/${id}`, body),
onSuccess: () => invalidate.profiles(queryClient),
});
if (!config.oidc_enabled || !config.user) {
return (
<div className="flex flex-col items-center justify-center py-20">
@@ -221,29 +366,6 @@ function OwnProfileView() {
const flashMessage = searchParams.get("message") || "";
const flashError = searchParams.get("error") || "";
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const body = {
name: String(data.get("name") ?? "").trim() || null,
callsign: String(data.get("callsign") ?? "").trim() || null,
description: String(data.get("description") ?? "").trim() || null,
url: String(data.get("url") ?? "").trim() || null,
};
try {
await updateMutation.mutateAsync({ id: profile.id, body });
navigate(
"/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")),
{ replace: true },
);
} catch (err) {
navigate(
"/profile?error=" + encodeURIComponent((err as Error).message),
{ replace: true },
);
}
};
return (
<>
<Breadcrumbs
@@ -266,63 +388,22 @@ function OwnProfileView() {
<div className="card-body">
<h2 className="card-title">{t("user_profile.your_profile")}</h2>
<RoleBadges roles={profile.roles} />
<form onSubmit={handleSubmit} className="py-4 space-y-4">
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.name_label")}
</span>
<input
type="text"
name="name"
className="input flex-1"
defaultValue={profile.name || ""}
placeholder={t("user_profile.name_placeholder")}
maxLength={255}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.callsign_label")}
</span>
<input
type="text"
name="callsign"
className="input flex-1"
defaultValue={profile.callsign || ""}
placeholder={t("user_profile.callsign_placeholder")}
maxLength={20}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.description_label")}
</span>
<input
type="text"
name="description"
className="input flex-1"
defaultValue={profile.description || ""}
placeholder={t("user_profile.description_placeholder")}
maxLength={500}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.url_label")}
</span>
<input
type="url"
name="url"
className="input flex-1"
defaultValue={profile.url || ""}
placeholder={t("user_profile.url_placeholder")}
maxLength={2048}
/>
</label>
<button type="submit" className="btn btn-primary btn-sm">
{t("user_profile.save_profile")}
</button>
</form>
<ProfileEditForm
profile={profile}
onSuccess={() =>
navigate(
"/profile?message=" +
encodeURIComponent(t("user_profile.profile_updated")),
{ replace: true },
)
}
onError={(msg) =>
navigate(
"/profile?error=" + encodeURIComponent(msg),
{ replace: true },
)
}
/>
<MemberSince createdAt={profile.created_at} />
</div>
</div>
+25
View File
@@ -28,6 +28,10 @@ NO_ROLES_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "",
}
ADMIN_HEADERS = {
"X-User-Id": OTHER_USER_ID,
"X-User-Roles": "admin",
}
class TestListProfiles:
@@ -383,6 +387,27 @@ class TestUpdateProfile:
)
assert response.status_code == 403
def test_update_profile_admin_can_edit_other(
self, client_no_auth, sample_user_profile
):
"""Test that an admin can update another user's profile."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={
"name": "Admin Edited",
"callsign": "ADM1",
"description": "Admin revised this",
"url": "https://example.com/admin",
},
headers=ADMIN_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Admin Edited"
assert data["callsign"] == "ADM1"
assert data["description"] == "Admin revised this"
assert data["url"] == "https://example.com/admin"
def test_update_profile_rejects_missing_user_id(
self, client_no_auth, sample_user_profile
):