From acacea0d85052e16ef3d041f0f7d4857b35e127b Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 24 Jul 2026 22:22:17 +0100 Subject: [PATCH] feat(routes): add 'my routes only' filter Adds a checkbox filter to the Routes page that narrows the list to routes owned by the current user. The filter is URL-driven (?mine=true), cached server-side automatically via the existing key builder, and only shown to operators/admins (members can't own routes). Backend: mine query param on GET /api/v1/routes filters by created_by matching the caller's X-User-Id. Legacy NULL routes are excluded. Tests: - Backend: 6 new TestRouteMineFilter tests (own/other/null/admin/default) - Vitest: 6 new tests (param passing, role gating, checkbox state) - E2E: new seed route owned by pw-operator + mine filter spec --- e2e/seed_data.py | 32 ++++ e2e/tests/routes-operator.spec.ts | 36 ++++- src/meshcore_hub/api/routes/routes.py | 17 +- .../static/js/spa-react/pages/Routes.test.tsx | 106 +++++++++++++ .../web/static/js/spa-react/pages/Routes.tsx | 35 +++- .../static/js/spa-react/utils/queryKeys.ts | 2 +- src/meshcore_hub/web/static/locales/en.json | 3 +- src/meshcore_hub/web/static/locales/nl.json | 3 +- tests/test_api/test_routes.py | 149 ++++++++++++++++++ 9 files changed, 373 insertions(+), 10 deletions(-) diff --git a/e2e/seed_data.py b/e2e/seed_data.py index 3ec4ed2..6c15c2c 100644 --- a/e2e/seed_data.py +++ b/e2e/seed_data.py @@ -407,6 +407,38 @@ def seed_routes( ) session.flush() + # A second route owned by the e2e operator session (pw-operator). + # Used by the "mine" filter test: operator-owned routes stay visible + # when ?mine=true is active, while the legacy NULL-created_by route above + # disappears. + op_route = Route( + from_label="Op North", + to_label="Op South", + description="Operator-owned e2e route", + visibility="operator", + match_width=1, + window_hours=24, + packet_count_threshold=3, + clear_threshold=6, + max_hop_span=8, + enabled=True, + reversible=False, + created_by="pw-operator", + ) + session.add(op_route) + session.flush() + for position, pk in enumerate((CHARLIE, DELTA)): + session.add( + RouteNode( + route_id=op_route.id, + node_id=nodes[pk].id, + position=position, + expected_hash=pk[:4].upper(), + ) + ) + session.add(RouteObserver(route_id=op_route.id, node_id=nodes[NORTH_2].id)) + session.flush() + def seed_profiles(session: Session, nodes: dict[str, Node]) -> None: specs = [ diff --git a/e2e/tests/routes-operator.spec.ts b/e2e/tests/routes-operator.spec.ts index acdc571..fecdc5e 100644 --- a/e2e/tests/routes-operator.spec.ts +++ b/e2e/tests/routes-operator.spec.ts @@ -4,6 +4,8 @@ import { OPERATOR_STATE } from "../utils/helpers"; test.use({ storageState: OPERATOR_STATE }); const ROUTE_LABEL = "Op From \u2192 Op To"; +const SEEDED_LEGACY = "Alpha Site \u2192 Bravo Site"; +const SEEDED_OWNED = "Op North \u2192 Op South"; test.describe.serial("routes (operator)", () => { test("operator can manage routes; admin visibility tier is hidden", async ({ @@ -13,7 +15,7 @@ test.describe.serial("routes (operator)", () => { // Operators see the seeded community route and the add button. const seededCard = page.locator( - '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]', + `[data-testid="route-card"][data-route-label="${SEEDED_LEGACY}"]`, ); await expect(seededCard).toBeVisible(); await expect(page.getByTestId("add-route")).toBeVisible(); @@ -61,4 +63,36 @@ test.describe.serial("routes (operator)", () => { await confirm.getByRole("button", { name: "Delete" }).click(); await expect(card).toHaveCount(0); }); + + test("mine filter shows only routes the operator owns", async ({ page }) => { + await page.goto("/routes"); + + const legacyCard = page.locator( + `[data-testid="route-card"][data-route-label="${SEEDED_LEGACY}"]`, + ); + const ownedCard = page.locator( + `[data-testid="route-card"][data-route-label="${SEEDED_OWNED}"]`, + ); + + // Both the legacy (NULL created_by) and operator-owned routes are visible. + await expect(legacyCard).toBeVisible(); + await expect(ownedCard).toBeVisible(); + + // Open the filter panel and toggle "mine". + await page.locator("#filter-toggle").check(); + await page.getByTestId("routes-mine-toggle").check(); + + // URL reflects the filter state. + await expect(page).toHaveURL(/mine=true/); + + // Legacy route (NULL created_by) disappears; owned route stays. + await expect(legacyCard).toHaveCount(0); + await expect(ownedCard).toBeVisible(); + + // Turn the filter off — both routes return. + await page.getByTestId("routes-mine-toggle").uncheck(); + await expect(page).not.toHaveURL(/mine=true/); + await expect(legacyCard).toBeVisible(); + await expect(ownedCard).toBeVisible(); + }); }); diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index a64d0d6..7e7fe7d 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -4,7 +4,7 @@ import logging from datetime import datetime, timedelta, timezone from typing import Any -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from sqlalchemy import select from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead, X_USER_ID_HEADER @@ -316,13 +316,26 @@ def list_routes( _: RequireRead, session: DbSession, request: Request, + mine: bool = Query( + default=False, description="Only return routes created by the caller" + ), ) -> RouteList: - """List routes, filtered by user role visibility.""" + """List routes, filtered by user role visibility. + + When ``mine`` is true, only routes whose ``created_by`` matches the + caller's user ID are returned (legacy routes with a NULL ``created_by`` + are always excluded in this mode). + """ role = resolve_user_role(request) max_level = get_max_visibility_level(role) + caller_id = request.headers.get(X_USER_ID_HEADER, "") routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all() visible = [r for r in routes if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level] + if mine: + visible = [ + r for r in visible if r.created_by is not None and r.created_by == caller_id + ] owners_by_id = _resolve_owners_batch(session, visible) filtered = [ _route_to_read( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx index 7412fbf..85892b1 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx @@ -305,3 +305,109 @@ describe("Routes per-route ownership gating", () => { expect(links.filter((l) => l.textContent === "Test User")).toHaveLength(0); }); }); + +describe("Routes mine filter", () => { + function buildConfig(roles: string[], userSub: string) { + return makeConfig({ + oidc_enabled: true, + roles, + role_names: { admin: "admin", operator: "operator", member: "member" }, + user: { sub: userSub, name: "Test User" }, + }); + } + + afterEach(() => { + window.__APP_CONFIG__ = makeConfig(); + }); + + it("passes mine=true to apiGet when URL has ?mine=true", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const spy = vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return ROUTES; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { + config: cfg, + route: "/routes?mine=true", + }); + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + "/api/v1/routes", + expect.objectContaining({ mine: "true" }), + expect.anything(), + ); + }); + }); + + it("omits mine param when URL has no ?mine=true", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const spy = vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return ROUTES; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg, route: "/routes" }); + await waitFor(() => { + expect(spy).toHaveBeenCalledWith( + "/api/v1/routes", + {}, + expect.anything(), + ); + }); + }); + + it("hides filter toggle for members", async () => { + const cfg = buildConfig(["member"], "mem-1"); + window.__APP_CONFIG__ = cfg; + mockRoutesApi(); + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("routes-mine-toggle")).toBeNull(); + expect(screen.queryByLabelText(/filters/i)).toBeNull(); + }); + + it("hides filter toggle when OIDC is disabled", async () => { + window.__APP_CONFIG__ = makeConfig(); + mockRoutesApi(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.queryByTestId("routes-mine-toggle")).toBeNull(); + }); + + it("shows the mine toggle for operators when filter panel is open", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + mockRoutesApi(); + renderWithProviders(, { config: cfg }); + await screen.findByTestId("add-route"); + fireEvent.click(screen.getByLabelText(/filters/i)); + expect(await screen.findByTestId("routes-mine-toggle")).toBeInTheDocument(); + }); + + it("checkbox is checked on load when URL has ?mine=true", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + mockRoutesApi(); + renderWithProviders(, { + config: cfg, + route: "/routes?mine=true", + }); + await screen.findByTestId("add-route"); + fireEvent.click(screen.getByLabelText(/filters/i)); + const toggle = (await screen.findByTestId( + "routes-mine-toggle", + )) as HTMLInputElement; + expect(toggle.checked).toBe(true); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx index 24bae55..921120e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -7,7 +7,7 @@ import { } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; @@ -16,6 +16,7 @@ import { usePageTitle } from "@/hooks/usePageTitle"; import { Loading, ErrorAlert } from "@/components/Alerts"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { EmptyState } from "@/components/EmptyState"; +import { FilterForm, FilterField, FilterToggle, autoSubmit } from "@/components/FilterForm"; import { Modal } from "@/components/Modal"; import { PageHeader } from "@/components/PageHeader"; import { SectionGroup } from "@/components/SectionGroup"; @@ -1181,6 +1182,7 @@ function DeleteRouteModal({ export function RoutesPage() { const { t } = useTranslation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const config = useAppConfig(); const packetsEnabled = config.features?.packets !== false; const canManage = hasRole("admin") || hasRole("operator"); @@ -1188,6 +1190,8 @@ export function RoutesPage() { const isAdmin = hasRole("admin"); const canEditRoute = (r: RouteItem) => isAdmin || (!!r.created_by && r.created_by === currentUserId); + const mine = searchParams.get("mine") === "true"; + const [filterOpen, setFilterOpen] = useState(false); usePageTitle("routes.title"); const queryClient = useQueryClient(); @@ -1197,11 +1201,11 @@ export function RoutesPage() { isLoading: loading, error: queryError, } = useQuery({ - queryKey: qk.routes.list(), + queryKey: qk.routes.list({ mine }), queryFn: async ({ signal }) => { const data = await apiGet( "/api/v1/routes", - {}, + mine ? { mine: "true" } : {}, { signal }, ); return data.items || []; @@ -1511,7 +1515,8 @@ export function RoutesPage() { {error && } {canManage && ( -
+
+ setFilterOpen((v) => !v)} />