Skip to content

Commit acacea0

Browse files
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
1 parent ca88e9f commit acacea0

9 files changed

Lines changed: 373 additions & 10 deletions

File tree

e2e/seed_data.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,38 @@ def seed_routes(
407407
)
408408
session.flush()
409409

410+
# A second route owned by the e2e operator session (pw-operator).
411+
# Used by the "mine" filter test: operator-owned routes stay visible
412+
# when ?mine=true is active, while the legacy NULL-created_by route above
413+
# disappears.
414+
op_route = Route(
415+
from_label="Op North",
416+
to_label="Op South",
417+
description="Operator-owned e2e route",
418+
visibility="operator",
419+
match_width=1,
420+
window_hours=24,
421+
packet_count_threshold=3,
422+
clear_threshold=6,
423+
max_hop_span=8,
424+
enabled=True,
425+
reversible=False,
426+
created_by="pw-operator",
427+
)
428+
session.add(op_route)
429+
session.flush()
430+
for position, pk in enumerate((CHARLIE, DELTA)):
431+
session.add(
432+
RouteNode(
433+
route_id=op_route.id,
434+
node_id=nodes[pk].id,
435+
position=position,
436+
expected_hash=pk[:4].upper(),
437+
)
438+
)
439+
session.add(RouteObserver(route_id=op_route.id, node_id=nodes[NORTH_2].id))
440+
session.flush()
441+
410442

411443
def seed_profiles(session: Session, nodes: dict[str, Node]) -> None:
412444
specs = [

e2e/tests/routes-operator.spec.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { OPERATOR_STATE } from "../utils/helpers";
44
test.use({ storageState: OPERATOR_STATE });
55

66
const ROUTE_LABEL = "Op From \u2192 Op To";
7+
const SEEDED_LEGACY = "Alpha Site \u2192 Bravo Site";
8+
const SEEDED_OWNED = "Op North \u2192 Op South";
79

810
test.describe.serial("routes (operator)", () => {
911
test("operator can manage routes; admin visibility tier is hidden", async ({
@@ -13,7 +15,7 @@ test.describe.serial("routes (operator)", () => {
1315

1416
// Operators see the seeded community route and the add button.
1517
const seededCard = page.locator(
16-
'[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]',
18+
`[data-testid="route-card"][data-route-label="${SEEDED_LEGACY}"]`,
1719
);
1820
await expect(seededCard).toBeVisible();
1921
await expect(page.getByTestId("add-route")).toBeVisible();
@@ -61,4 +63,36 @@ test.describe.serial("routes (operator)", () => {
6163
await confirm.getByRole("button", { name: "Delete" }).click();
6264
await expect(card).toHaveCount(0);
6365
});
66+
67+
test("mine filter shows only routes the operator owns", async ({ page }) => {
68+
await page.goto("/routes");
69+
70+
const legacyCard = page.locator(
71+
`[data-testid="route-card"][data-route-label="${SEEDED_LEGACY}"]`,
72+
);
73+
const ownedCard = page.locator(
74+
`[data-testid="route-card"][data-route-label="${SEEDED_OWNED}"]`,
75+
);
76+
77+
// Both the legacy (NULL created_by) and operator-owned routes are visible.
78+
await expect(legacyCard).toBeVisible();
79+
await expect(ownedCard).toBeVisible();
80+
81+
// Open the filter panel and toggle "mine".
82+
await page.locator("#filter-toggle").check();
83+
await page.getByTestId("routes-mine-toggle").check();
84+
85+
// URL reflects the filter state.
86+
await expect(page).toHaveURL(/mine=true/);
87+
88+
// Legacy route (NULL created_by) disappears; owned route stays.
89+
await expect(legacyCard).toHaveCount(0);
90+
await expect(ownedCard).toBeVisible();
91+
92+
// Turn the filter off — both routes return.
93+
await page.getByTestId("routes-mine-toggle").uncheck();
94+
await expect(page).not.toHaveURL(/mine=true/);
95+
await expect(legacyCard).toBeVisible();
96+
await expect(ownedCard).toBeVisible();
97+
});
6498
});

src/meshcore_hub/api/routes/routes.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime, timedelta, timezone
55
from typing import Any
66

7-
from fastapi import APIRouter, HTTPException, Request
7+
from fastapi import APIRouter, HTTPException, Query, Request
88
from sqlalchemy import select
99

1010
from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead, X_USER_ID_HEADER
@@ -316,13 +316,26 @@ def list_routes(
316316
_: RequireRead,
317317
session: DbSession,
318318
request: Request,
319+
mine: bool = Query(
320+
default=False, description="Only return routes created by the caller"
321+
),
319322
) -> RouteList:
320-
"""List routes, filtered by user role visibility."""
323+
"""List routes, filtered by user role visibility.
324+
325+
When ``mine`` is true, only routes whose ``created_by`` matches the
326+
caller's user ID are returned (legacy routes with a NULL ``created_by``
327+
are always excluded in this mode).
328+
"""
321329
role = resolve_user_role(request)
322330
max_level = get_max_visibility_level(role)
331+
caller_id = request.headers.get(X_USER_ID_HEADER, "")
323332

324333
routes = session.execute(select(Route).order_by(Route.from_label)).scalars().all()
325334
visible = [r for r in routes if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level]
335+
if mine:
336+
visible = [
337+
r for r in visible if r.created_by is not None and r.created_by == caller_id
338+
]
326339
owners_by_id = _resolve_owners_batch(session, visible)
327340
filtered = [
328341
_route_to_read(

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,109 @@ describe("Routes per-route ownership gating", () => {
305305
expect(links.filter((l) => l.textContent === "Test User")).toHaveLength(0);
306306
});
307307
});
308+
309+
describe("Routes mine filter", () => {
310+
function buildConfig(roles: string[], userSub: string) {
311+
return makeConfig({
312+
oidc_enabled: true,
313+
roles,
314+
role_names: { admin: "admin", operator: "operator", member: "member" },
315+
user: { sub: userSub, name: "Test User" },
316+
});
317+
}
318+
319+
afterEach(() => {
320+
window.__APP_CONFIG__ = makeConfig();
321+
});
322+
323+
it("passes mine=true to apiGet when URL has ?mine=true", async () => {
324+
const cfg = buildConfig(["operator"], "op-1");
325+
window.__APP_CONFIG__ = cfg;
326+
const spy = vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
327+
if (path === "/api/v1/routes") return ROUTES;
328+
if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL;
329+
if (path.includes("/history")) return ROUTE_HISTORY;
330+
throw new Error(`Unexpected: ${path}`);
331+
});
332+
333+
renderWithProviders(<Routes />, {
334+
config: cfg,
335+
route: "/routes?mine=true",
336+
});
337+
await waitFor(() => {
338+
expect(spy).toHaveBeenCalledWith(
339+
"/api/v1/routes",
340+
expect.objectContaining({ mine: "true" }),
341+
expect.anything(),
342+
);
343+
});
344+
});
345+
346+
it("omits mine param when URL has no ?mine=true", async () => {
347+
const cfg = buildConfig(["operator"], "op-1");
348+
window.__APP_CONFIG__ = cfg;
349+
const spy = vi.spyOn(api, "apiGet").mockImplementation(async (path) => {
350+
if (path === "/api/v1/routes") return ROUTES;
351+
if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL;
352+
if (path.includes("/history")) return ROUTE_HISTORY;
353+
throw new Error(`Unexpected: ${path}`);
354+
});
355+
356+
renderWithProviders(<Routes />, { config: cfg, route: "/routes" });
357+
await waitFor(() => {
358+
expect(spy).toHaveBeenCalledWith(
359+
"/api/v1/routes",
360+
{},
361+
expect.anything(),
362+
);
363+
});
364+
});
365+
366+
it("hides filter toggle for members", async () => {
367+
const cfg = buildConfig(["member"], "mem-1");
368+
window.__APP_CONFIG__ = cfg;
369+
mockRoutesApi();
370+
renderWithProviders(<Routes />, { config: cfg });
371+
await waitFor(() => {
372+
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
373+
});
374+
expect(screen.queryByTestId("routes-mine-toggle")).toBeNull();
375+
expect(screen.queryByLabelText(/filters/i)).toBeNull();
376+
});
377+
378+
it("hides filter toggle when OIDC is disabled", async () => {
379+
window.__APP_CONFIG__ = makeConfig();
380+
mockRoutesApi();
381+
renderWithProviders(<Routes />);
382+
await waitFor(() => {
383+
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
384+
});
385+
expect(screen.queryByTestId("routes-mine-toggle")).toBeNull();
386+
});
387+
388+
it("shows the mine toggle for operators when filter panel is open", async () => {
389+
const cfg = buildConfig(["operator"], "op-1");
390+
window.__APP_CONFIG__ = cfg;
391+
mockRoutesApi();
392+
renderWithProviders(<Routes />, { config: cfg });
393+
await screen.findByTestId("add-route");
394+
fireEvent.click(screen.getByLabelText(/filters/i));
395+
expect(await screen.findByTestId("routes-mine-toggle")).toBeInTheDocument();
396+
});
397+
398+
it("checkbox is checked on load when URL has ?mine=true", async () => {
399+
const cfg = buildConfig(["operator"], "op-1");
400+
window.__APP_CONFIG__ = cfg;
401+
mockRoutesApi();
402+
renderWithProviders(<Routes />, {
403+
config: cfg,
404+
route: "/routes?mine=true",
405+
});
406+
await screen.findByTestId("add-route");
407+
fireEvent.click(screen.getByLabelText(/filters/i));
408+
const toggle = (await screen.findByTestId(
409+
"routes-mine-toggle",
410+
)) as HTMLInputElement;
411+
expect(toggle.checked).toBe(true);
412+
});
413+
});

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "react";
88
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
99
import { useTranslation } from "react-i18next";
10-
import { Link, useNavigate } from "react-router";
10+
import { Link, useNavigate, useSearchParams } from "react-router";
1111

1212
import { useAppConfig, hasRole } from "@/context/AppConfigContext";
1313
import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api";
@@ -16,6 +16,7 @@ import { usePageTitle } from "@/hooks/usePageTitle";
1616
import { Loading, ErrorAlert } from "@/components/Alerts";
1717
import { ConfirmDialog } from "@/components/ConfirmDialog";
1818
import { EmptyState } from "@/components/EmptyState";
19+
import { FilterForm, FilterField, FilterToggle, autoSubmit } from "@/components/FilterForm";
1920
import { Modal } from "@/components/Modal";
2021
import { PageHeader } from "@/components/PageHeader";
2122
import { SectionGroup } from "@/components/SectionGroup";
@@ -1181,13 +1182,16 @@ function DeleteRouteModal({
11811182
export function RoutesPage() {
11821183
const { t } = useTranslation();
11831184
const navigate = useNavigate();
1185+
const [searchParams] = useSearchParams();
11841186
const config = useAppConfig();
11851187
const packetsEnabled = config.features?.packets !== false;
11861188
const canManage = hasRole("admin") || hasRole("operator");
11871189
const currentUserId = config.user?.sub;
11881190
const isAdmin = hasRole("admin");
11891191
const canEditRoute = (r: RouteItem) =>
11901192
isAdmin || (!!r.created_by && r.created_by === currentUserId);
1193+
const mine = searchParams.get("mine") === "true";
1194+
const [filterOpen, setFilterOpen] = useState(false);
11911195
usePageTitle("routes.title");
11921196

11931197
const queryClient = useQueryClient();
@@ -1197,11 +1201,11 @@ export function RoutesPage() {
11971201
isLoading: loading,
11981202
error: queryError,
11991203
} = useQuery({
1200-
queryKey: qk.routes.list(),
1204+
queryKey: qk.routes.list({ mine }),
12011205
queryFn: async ({ signal }) => {
12021206
const data = await apiGet<RouteListResponse>(
12031207
"/api/v1/routes",
1204-
{},
1208+
mine ? { mine: "true" } : {},
12051209
{ signal },
12061210
);
12071211
return data.items || [];
@@ -1511,7 +1515,8 @@ export function RoutesPage() {
15111515
{error && <ErrorAlert message={error} />}
15121516

15131517
{canManage && (
1514-
<div className="flex justify-end mb-4">
1518+
<div className="flex items-center justify-between mb-4 gap-2">
1519+
<FilterToggle open={filterOpen} onChange={() => setFilterOpen((v) => !v)} />
15151520
<button
15161521
className="btn btn-primary btn-sm"
15171522
data-testid="add-route"
@@ -1522,6 +1527,28 @@ export function RoutesPage() {
15221527
</div>
15231528
)}
15241529

1530+
{filterOpen && canManage && (
1531+
<div className="mb-4">
1532+
<FilterForm basePath="/routes">
1533+
<FilterField label={t("routes.filter_mine")}>
1534+
<label className="label cursor-pointer justify-start gap-2 py-1">
1535+
<input
1536+
type="checkbox"
1537+
name="mine"
1538+
value="true"
1539+
data-testid="routes-mine-toggle"
1540+
className="checkbox checkbox-sm"
1541+
key={`mine-${mine}`}
1542+
defaultChecked={mine}
1543+
onChange={autoSubmit}
1544+
/>
1545+
<span className="text-sm">{t("routes.filter_mine")}</span>
1546+
</label>
1547+
</FilterField>
1548+
</FilterForm>
1549+
</div>
1550+
)}
1551+
15251552
{routes.length === 0 && (
15261553
<EmptyState>
15271554
{t("common.no_entity_found", {

src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const qk = {
1717
},
1818
routes: {
1919
all: ["routes"] as const,
20-
list: () => ["routes", "list"] as const,
20+
list: (params: unknown = {}) => ["routes", "list", params] as const,
2121
detail: (id: string) => ["routes", "detail", id] as const,
2222
history: (id: string, days: number) =>
2323
["routes", "history", id, days] as const,

src/meshcore_hub/web/static/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,8 @@
361361
"recent_packets": "Recent Packets",
362362
"last_n_hours": "Last {{n}}h",
363363
"min_nodes_error": "At least 2 path nodes are required.",
364-
"other_routes": "Other routes"
364+
"other_routes": "Other routes",
365+
"filter_mine": "Show only my routes"
365366
},
366367
"not_found": {
367368
"description": "The page you're looking for doesn't exist or has been moved."

src/meshcore_hub/web/static/locales/nl.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@
283283
"recent_packets": "Recente packets",
284284
"last_n_hours": "Laatste {{n}}u",
285285
"min_nodes_error": "Minimaal 2 padknooppunten zijn vereist.",
286-
"other_routes": "Overige routes"
286+
"other_routes": "Overige routes",
287+
"filter_mine": "Toon alleen mijn routes"
287288
},
288289
"not_found": {
289290
"description": "De pagina die u zoekt bestaat niet of is verplaatst."

0 commit comments

Comments
 (0)