diff --git a/alembic/versions/20260724_1000_da69304d8106_add_routes_created_by.py b/alembic/versions/20260724_1000_da69304d8106_add_routes_created_by.py new file mode 100644 index 0000000..24a346f --- /dev/null +++ b/alembic/versions/20260724_1000_da69304d8106_add_routes_created_by.py @@ -0,0 +1,36 @@ +"""add routes.created_by + +Revision ID: da69304d8106 +Revises: d307ee761a34 +Create Date: 2026-07-24 10:00:00.000000+00:00 + +Adds a nullable ``created_by`` column to ``routes``, storing the OIDC +subject identifier (``user_id``) of the operator or admin who created +the route. This enables ownership-based write permissions: operators +can only modify routes they created, while admins can modify any route +and take ownership on edit. + +Existing routes get ``NULL`` (admin-only modification), preserving +backwards compatibility with no data backfill. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "da69304d8106" +down_revision: Union[str, None] = "d307ee761a34" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("routes", schema=None) as batch_op: + batch_op.add_column(sa.Column("created_by", sa.String(255), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("routes", schema=None) as batch_op: + batch_op.drop_column("created_by") diff --git a/docs/auth.md b/docs/auth.md index ec976e5..bb0af3c 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -21,7 +21,7 @@ User roles are read from the OIDC token's `roles` claim (configurable via `OIDC_ | Role | Config Variable | Default | Description | |------|----------------|---------|-------------| | Admin | `OIDC_ROLE_ADMIN` | `admin` | Full write access to all API endpoints through the proxy | -| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Manage nodes, node tags, adoptions, and routes (create/edit/delete, scoped to the operator visibility tier) | +| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Manage nodes, node tags, adoptions, and routes (create; edit/delete only routes they created) | | Member | `OIDC_ROLE_MEMBER` | `member` | Read-only access (no endpoint assignments) | The role names are configurable to match your IdP's role naming convention. For example, if your IdP uses `superuser` instead of `admin`, set `OIDC_ROLE_ADMIN=superuser`. diff --git a/docs/routes.md b/docs/routes.md index b702851..5c32276 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -44,7 +44,9 @@ The collector runs a background thread that re-evaluates every enabled route on Routes carry the same role-based visibility levels as channels — `community`, `member`, `operator`, `admin`. A user only sees routes whose visibility is at or below their role's maximum level. Seeded routes default to `community` (visible to everyone); set a higher level to restrict a route to operators/admins only. Visibility is enforced on both the list and detail endpoints, so a hidden route's existence is not leaked. -Both operators and admins can create, edit, and delete routes. A user may never scope a route above their own role (e.g. an operator cannot create an `admin`-visibility route) — this is enforced on the write endpoints and prevents a user from creating a route they could then never see or modify. Operators can only edit/delete routes whose visibility is at or below the operator tier; attempting to modify a higher-visibility route returns `404`. +Both operators and admins can create routes. A user may never scope a route above their own role (e.g. an operator cannot create an `admin`-visibility route) — this is enforced on the write endpoints and prevents a user from creating a route they could then never see or modify. + +**Ownership-based editing:** Each route records the user who created it (`created_by`). Operators can edit and delete only the routes they created. Admins can edit and delete any route; they claim ownership of legacy (unowned) routes — those with a `NULL` `created_by` — when they edit them, but do not displace an existing creator. Routes created before ownership tracking was introduced have a `NULL` `created_by` and are admin-only. The creator's display name is shown on each route card when available. Attempting to modify a route above the caller's visibility tier returns `404`; modifying a visible route the caller does not own returns `403`. ## Defining routes @@ -53,4 +55,4 @@ Routes are keyed by their `from`/`to` endpoint labels and upserted by that pair. - **Seed YAML** — add a `routes.yaml` to your `SEED_HOME` and run the seed process. See [seeding.md → Routes](seeding.md#routes) for the format and rules (path nodes must already exist in the database; the `(from, to)` pair must be unique). - **API** — `POST /api/v1/routes` (operator or admin) creates a route, with a `/preview` endpoint that dry-runs matching against an unsaved configuration so you can tune thresholds before committing. See `SCHEMAS.md` for the request/response shapes. -The `/routes` page renders the live status card, the per-day history strip, recent matching transmissions (with observer attribution), and — for operators and admins — inline edit/delete controls. +The `/routes` page renders the live status card, the per-day history strip, recent matching transmissions (with observer attribution), the route owner's name, and inline edit/delete controls gated per-route by ownership (operators see controls only on their own routes; admins see them on all routes). diff --git a/e2e/tests/routes-operator.spec.ts b/e2e/tests/routes-operator.spec.ts index 23f31ee..acdc571 100644 --- a/e2e/tests/routes-operator.spec.ts +++ b/e2e/tests/routes-operator.spec.ts @@ -12,13 +12,16 @@ test.describe.serial("routes (operator)", () => { await page.goto("/routes"); // Operators see the seeded community route and the add button. - await expect( - page.locator( - '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]', - ), - ).toBeVisible(); + const seededCard = page.locator( + '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]', + ); + await expect(seededCard).toBeVisible(); await expect(page.getByTestId("add-route")).toBeVisible(); + // The seeded route has NULL created_by — operator must NOT see edit/delete. + await expect(seededCard.getByTestId("edit-route")).toHaveCount(0); + await expect(seededCard.getByTestId("delete-route")).toHaveCount(0); + // The visibility dropdown must NOT offer the admin tier to an operator. await page.getByTestId("add-route").click(); const modal = page.locator('[data-testid="route-modal"]'); @@ -45,7 +48,7 @@ test.describe.serial("routes (operator)", () => { ); await expect(card).toBeVisible(); - // Operator can edit the route they created. + // Operator owns the route they just created — edit/delete should be present. await card.getByTestId("edit-route").click(); await expect(modal).toBeVisible(); await expect(page.getByTestId("route-from")).toHaveValue("Op From"); diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts index cfaa56a..e64c9d7 100644 --- a/e2e/tests/routes.spec.ts +++ b/e2e/tests/routes.spec.ts @@ -11,9 +11,14 @@ test.describe.serial("routes (admin)", () => { }) => { await page.goto("/routes"); - await expect( - page.locator('[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]'), - ).toBeVisible(); + const seededCard = page.locator( + '[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]', + ); + await expect(seededCard).toBeVisible(); + + // Admin sees edit/delete on all routes, including legacy (NULL created_by) ones. + await expect(seededCard.getByTestId("edit-route")).toBeVisible(); + await expect(seededCard.getByTestId("delete-route")).toBeVisible(); await page.getByTestId("add-route").click(); const modal = page.locator('[data-testid="route-modal"]'); diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index 8066163..a64d0d6 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -1,12 +1,13 @@ """Route health monitoring API routes.""" +import logging from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, HTTPException, Request from sqlalchemy import select -from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead +from meshcore_hub.api.auth import RequireOperatorOrAdmin, RequireRead, X_USER_ID_HEADER from meshcore_hub.api.cache import cached, sorted_query_string from meshcore_hub.api.cache_invalidation import invalidate_routes from meshcore_hub.api.channel_visibility import ( @@ -15,6 +16,7 @@ from meshcore_hub.api.channel_visibility import ( resolve_user_role, ) from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.api.profile_utils import get_or_create_profile from meshcore_hub.collector.routes import ( compute_persisted_quality_avg, derive_expected_hash, @@ -34,6 +36,7 @@ from meshcore_hub.common.models.route_node import RouteNode from meshcore_hub.common.models.route_observer import RouteObserver from meshcore_hub.common.models.route_recent_match import RouteRecentMatch from meshcore_hub.common.models.route_result import RouteResult +from meshcore_hub.common.models.user_profile import UserProfile from meshcore_hub.common.schemas.routes import ( ContributingObserver, RecentMatchPath, @@ -44,6 +47,7 @@ from meshcore_hub.common.schemas.routes import ( RouteList, RouteNodeRead, RouteObserverRead, + RouteOwner, RoutePreviewRequest, RoutePreviewResponse, RouteRead, @@ -52,6 +56,7 @@ from meshcore_hub.common.schemas.routes import ( ) router = APIRouter() +logger = logging.getLogger(__name__) # Sentinel distinguishing "use the precomputed value" from "explicitly None" # (the latter is what create_route passes to preserve the "brand-new route @@ -88,15 +93,28 @@ def _assert_visibility_within_role(request: Request, visibility: str) -> None: def _assert_route_modifiable(request: Request, route: Route) -> None: - """Reject modifying a route above the caller's visibility tier. + """Reject modifying a route the caller is not allowed to touch. - Returns 404 (mirroring the GET detail behaviour) so the existence of a - higher-visibility route is not leaked to lower-privileged callers. + Layer 1 — visibility: a route above the caller's tier yields **404** + so its existence is not leaked (mirrors GET detail behaviour). + + Layer 2 — ownership: operators may only modify routes *they* created. + A visible-but-unowned route yields **403** (the caller can see it in + the list, so a transparent rejection is better UX). ``created_by`` + is ``None`` for legacy routes (pre-ownership-tracking) and is treated + as admin-only. Admins bypass the ownership check entirely. """ if VISIBILITY_LEVELS.get(route.visibility, 0) > _caller_max_visibility_level( request ): raise HTTPException(status_code=404, detail="Route not found") + if resolve_user_role(request) != "admin": + caller_id = request.headers.get(X_USER_ID_HEADER, "") + if route.created_by is None or route.created_by != caller_id: + raise HTTPException( + status_code=403, + detail="You can only modify routes you created", + ) def _route_node_to_read(rn: RouteNode) -> RouteNodeRead: @@ -130,13 +148,58 @@ def _result_to_summary(result: RouteResult | None) -> RouteResultSummary | None: ) -def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead: +def _profile_to_owner(profile: UserProfile) -> RouteOwner: + """Convert a UserProfile to the lightweight RouteOwner display schema.""" + return RouteOwner( + user_id=profile.user_id, + name=profile.name, + callsign=profile.callsign, + profile_id=profile.id, + ) + + +def _resolve_owner(session: DbSession, created_by: str | None) -> UserProfile | None: + """Resolve a single ``created_by`` user_id to a UserProfile.""" + if not created_by: + return None + return session.execute( + select(UserProfile).where(UserProfile.user_id == created_by) + ).scalar_one_or_none() + + +def _resolve_owners_batch( + session: DbSession, routes: list[Route] +) -> dict[str, UserProfile]: + """Batch-resolve creator profiles for a list of routes (avoids N+1).""" + owner_ids = {r.created_by for r in routes if r.created_by} + if not owner_ids: + return {} + return { + p.user_id: p + for p in session.execute( + select(UserProfile).where(UserProfile.user_id.in_(owner_ids)) + ) + .scalars() + .all() + } + + +def _route_to_read( + route: Route, + *, + quality_avg: Any = _UNSET, + owner: UserProfile | None = None, +) -> RouteRead: """Serialize a Route to its list-level read schema. ``quality_avg`` defaults to the precomputed value persisted on ``route.route_result.quality_avg`` (written by the background evaluator). Callers may pass an explicit value (e.g. ``None`` on create responses) to override. + + ``owner`` is the resolved UserProfile for ``route.created_by``, if + any. Callers should pass it in to avoid per-row queries in list + contexts (use ``_resolve_owners_batch``). """ if quality_avg is _UNSET: quality_avg = route.route_result.quality_avg if route.route_result else None @@ -158,6 +221,8 @@ def _route_to_read(route: Route, *, quality_avg: Any = _UNSET) -> RouteRead: route_observers=[_route_observer_to_read(ro) for ro in route.route_observers], route_result=_result_to_summary(route.route_result), quality_avg=quality_avg, + created_by=route.created_by, + owner=_profile_to_owner(owner) if owner else None, created_at=route.created_at, updated_at=route.updated_at, ) @@ -257,22 +322,27 @@ def list_routes( max_level = get_max_visibility_level(role) 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] + owners_by_id = _resolve_owners_batch(session, visible) filtered = [ - _route_to_read(r) - for r in routes - if VISIBILITY_LEVELS.get(r.visibility, 0) <= max_level + _route_to_read( + r, owner=owners_by_id.get(r.created_by) if r.created_by else None + ) + for r in visible ] return RouteList(items=filtered, total=len(filtered)) @router.post("", response_model=RouteRead, status_code=201) def create_route( - __: RequireOperatorOrAdmin, + caller: RequireOperatorOrAdmin, session: DbSession, body: RouteCreate, request: Request, ) -> RouteRead: """Create a new route (operator or admin).""" + user_id, _ = caller + get_or_create_profile(session, user_id, request) _assert_visibility_within_role(request, body.visibility) existing = session.execute( select(Route).where( @@ -309,6 +379,7 @@ def create_route( max_path_length=body.max_path_length, enabled=body.enabled, reversible=body.reversible, + created_by=user_id, ) session.add(route) session.flush() @@ -318,7 +389,8 @@ def create_route( session.refresh(route) _reevaluate_route(session, route) invalidate_routes(request) - return _route_to_read(route, quality_avg=None) + owner = _resolve_owner(session, route.created_by) + return _route_to_read(route, quality_avg=None, owner=owner) @router.get("/{route_id}", response_model=RouteDetail) @@ -374,7 +446,7 @@ def get_route( for oid, cnt in contributing.items() ] - read = _route_to_read(route) + read = _route_to_read(route, owner=_resolve_owner(session, route.created_by)) return RouteDetail( **read.model_dump(), contributing_observers=contributors, @@ -566,13 +638,19 @@ def get_route_history( @router.put("/{route_id}", response_model=RouteRead) def update_route( - __: RequireOperatorOrAdmin, + caller: RequireOperatorOrAdmin, session: DbSession, route_id: str, body: RouteUpdate, request: Request, ) -> RouteRead: - """Update a route (operator or admin).""" + """Update a route (operator or admin). + + Operators may only modify routes they created; admins can modify any + route. Admins claim ownership of legacy (unowned) routes on edit but + do not displace an existing creator. + """ + user_id, _ = caller route = session.execute( select(Route).where(Route.id == route_id) ).scalar_one_or_none() @@ -580,6 +658,11 @@ def update_route( raise HTTPException(status_code=404, detail="Route not found") _assert_route_modifiable(request, route) + # Admin claims ownership of legacy (unowned) routes on edit + if resolve_user_role(request) == "admin" and route.created_by is None: + route.created_by = user_id + logger.info("Admin %s claimed ownership of legacy route %s", user_id, route.id) + if body.from_label is not None or body.to_label is not None: new_from = body.from_label if body.from_label is not None else route.from_label new_to = body.to_label if body.to_label is not None else route.to_label @@ -636,7 +719,8 @@ def update_route( session.refresh(route) _reevaluate_route(session, route) invalidate_routes(request) - return _route_to_read(route) + owner = _resolve_owner(session, route.created_by) + return _route_to_read(route, owner=owner) @router.delete("/{route_id}", status_code=204) diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py index 6cab52c..4fefc94 100644 --- a/src/meshcore_hub/common/models/route.py +++ b/src/meshcore_hub/common/models/route.py @@ -103,6 +103,10 @@ class Route(Base, UUIDMixin, TimestampMixin): server_default="true", nullable=False, ) + created_by: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + ) route_nodes: Mapped[list["RouteNode"]] = relationship( "RouteNode", diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index 8af0966..b731866 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -142,6 +142,17 @@ class RouteUpdate(BaseModel): return self +class RouteOwner(BaseModel): + """Creator/owner of a route, for display on route cards.""" + + user_id: str + name: Optional[str] = None + callsign: Optional[str] = None + profile_id: str + + model_config = {"from_attributes": True} + + class RouteRead(BaseModel): """Schema for reading a route (list-level with lightweight result).""" @@ -169,6 +180,8 @@ class RouteRead(BaseModel): "back to ``route_result.quality`` for brand-new routes." ), ) + created_by: Optional[str] = None + owner: Optional[RouteOwner] = None created_at: datetime updated_at: datetime @@ -227,6 +240,8 @@ class RouteDetail(BaseModel): ) contributing_observers: list[ContributingObserver] = [] recent_matches: list[RecentMatchPath] = [] + created_by: Optional[str] = None + owner: Optional[RouteOwner] = None created_at: datetime updated_at: datetime 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 3d35b69..7412fbf 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 @@ -30,6 +30,8 @@ const ROUTES = { route_result: { quality: "clear", state: "healthy" }, route_nodes: [], route_observers: [], + created_by: null, + owner: null, }, ], }; @@ -135,3 +137,171 @@ describe("Routes role-gated management", () => { expect(values).not.toContain("admin"); }); }); + +describe("Routes per-route ownership gating", () => { + 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("hides edit/delete on routes the operator does not own", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const data = { + items: [ + { + ...ROUTES.items[0], + id: "other", + from_label: "OtherRoute", + created_by: "different-op", + owner: null, + }, + ], + }; + vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return data; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.getByText("OtherRoute")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("edit-route")).toBeNull(); + expect(screen.queryByTestId("delete-route")).toBeNull(); + }); + + it("shows edit/delete on routes the operator owns", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const data = { + items: [ + { + ...ROUTES.items[0], + id: "mine", + from_label: "MyRoute", + created_by: "op-1", + owner: { user_id: "op-1", name: "Test Operator", profile_id: "p1" }, + }, + ], + }; + vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return data; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.queryByTestId("edit-route")).not.toBeNull(); + }); + expect(screen.getByTestId("edit-route")).toBeInTheDocument(); + expect(screen.getByTestId("delete-route")).toBeInTheDocument(); + }); + + it("shows edit/delete on all routes for an admin", async () => { + const cfg = buildConfig(["admin"], "adm-1"); + window.__APP_CONFIG__ = cfg; + const data = { + items: [ + { + ...ROUTES.items[0], + id: "legacy", + from_label: "LegacyRoute", + created_by: null, + owner: null, + }, + { + ...ROUTES.items[0], + id: "other", + from_label: "OtherRoute", + created_by: "op-99", + owner: { user_id: "op-99", name: "Someone", profile_id: "p2" }, + }, + ], + }; + vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return data; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.getByText("LegacyRoute")).toBeInTheDocument(); + }); + const editButtons = screen.getAllByTestId("edit-route"); + const deleteButtons = screen.getAllByTestId("delete-route"); + expect(editButtons).toHaveLength(2); + expect(deleteButtons).toHaveLength(2); + }); + + it("displays the owner name badge when set", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const data = { + items: [ + { + ...ROUTES.items[0], + id: "named", + from_label: "NamedRoute", + created_by: "op-1", + owner: { user_id: "op-1", name: "Alice", profile_id: "p1" }, + }, + ], + }; + vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return data; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); + }); + + it("hides the owner badge when no owner is set", async () => { + const cfg = buildConfig(["operator"], "op-1"); + window.__APP_CONFIG__ = cfg; + const data = { + items: [ + { + ...ROUTES.items[0], + id: "legacy", + from_label: "LegacyRoute", + created_by: null, + owner: null, + }, + ], + }; + vi.spyOn(api, "apiGet").mockImplementation(async (path) => { + if (path === "/api/v1/routes") return data; + if (path.match(/\/api\/v1\/routes\/[^/]+$/)) return ROUTE_DETAIL; + if (path.includes("/history")) return ROUTE_HISTORY; + throw new Error(`Unexpected: ${path}`); + }); + + renderWithProviders(, { config: cfg }); + await waitFor(() => { + expect(screen.getByText("LegacyRoute")).toBeInTheDocument(); + }); + // No owner link should be present (only the route card title contains "LegacyRoute") + const links = screen.queryAllByRole("link"); + expect(links.filter((l) => l.textContent === "Test User")).toHaveLength(0); + }); +}); 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 bbc3998..24bae55 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 { useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; @@ -36,6 +36,7 @@ import { IconRuler, IconSatelliteDish, IconTrash, + IconUser, } from "@/components/icons"; interface RouteResultInfo { @@ -58,6 +59,13 @@ interface RouteObserverInfo { name?: string | null; } +interface RouteOwnerInfo { + user_id: string; + name?: string | null; + callsign?: string | null; + profile_id: string; +} + interface RouteItem { id: string; from_label?: string | null; @@ -76,6 +84,8 @@ interface RouteItem { route_result?: RouteResultInfo | null; route_nodes?: RouteNodeInfo[]; route_observers?: RouteObserverInfo[]; + created_by?: string | null; + owner?: RouteOwnerInfo | null; } interface RouteListResponse { @@ -495,14 +505,14 @@ function DetailContent({ function RouteCard({ route, - canManage, + canEdit, packetsEnabled, onEdit, onDelete, onNavigate, }: { route: RouteItem; - canManage: boolean; + canEdit: boolean; packetsEnabled: boolean; onEdit: () => void; onDelete: () => void; @@ -577,6 +587,21 @@ function RouteCard({ + {route.created_by && ( +
+ + {route.owner ? ( + + {route.owner.name || route.owner.user_id} + + ) : ( + {route.created_by} + )} +
+ )} {detail ? ( )} - {canManage && ( + {canEdit && (