Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions e2e/seed_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
36 changes: 35 additions & 1 deletion e2e/tests/routes-operator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand All @@ -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();
Expand Down Expand Up @@ -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();
});
});
17 changes: 15 additions & 2 deletions src/meshcore_hub/api/routes/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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
Expand Down Expand Up @@ -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(
Expand Down
106 changes: 106 additions & 0 deletions src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Routes />, {
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(<Routes />, { 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(<Routes />, { 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(<Routes />);
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(<Routes />, { 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(<Routes />, {
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);
});
});
35 changes: 31 additions & 4 deletions src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -1181,13 +1182,16 @@ 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");
const currentUserId = config.user?.sub;
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();
Expand All @@ -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<RouteListResponse>(
"/api/v1/routes",
{},
mine ? { mine: "true" } : {},
{ signal },
);
return data.items || [];
Expand Down Expand Up @@ -1511,7 +1515,8 @@ export function RoutesPage() {
{error && <ErrorAlert message={error} />}

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

{filterOpen && canManage && (
<div className="mb-4">
<FilterForm basePath="/routes">
<FilterField label={t("routes.filter_mine")}>
<label className="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
name="mine"
value="true"
data-testid="routes-mine-toggle"
className="checkbox checkbox-sm"
key={`mine-${mine}`}
defaultChecked={mine}
onChange={autoSubmit}
/>
<span className="text-sm">{t("routes.filter_mine")}</span>
</label>
</FilterField>
</FilterForm>
</div>
)}

{routes.length === 0 && (
<EmptyState>
{t("common.no_entity_found", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const qk = {
},
routes: {
all: ["routes"] as const,
list: () => ["routes", "list"] as const,
list: (params: unknown = {}) => ["routes", "list", params] as const,
detail: (id: string) => ["routes", "detail", id] as const,
history: (id: string, days: number) =>
["routes", "history", id, days] as const,
Expand Down
3 changes: 2 additions & 1 deletion src/meshcore_hub/web/static/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@
"recent_packets": "Recent Packets",
"last_n_hours": "Last {{n}}h",
"min_nodes_error": "At least 2 path nodes are required.",
"other_routes": "Other routes"
"other_routes": "Other routes",
"filter_mine": "Show only my routes"
},
"not_found": {
"description": "The page you're looking for doesn't exist or has been moved."
Expand Down
3 changes: 2 additions & 1 deletion src/meshcore_hub/web/static/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@
"recent_packets": "Recente packets",
"last_n_hours": "Laatste {{n}}u",
"min_nodes_error": "Minimaal 2 padknooppunten zijn vereist.",
"other_routes": "Overige routes"
"other_routes": "Overige routes",
"filter_mine": "Toon alleen mijn routes"
},
"not_found": {
"description": "De pagina die u zoekt bestaat niet of is verplaatst."
Expand Down
Loading
Loading