From 31418e6847ec708de4158e07bafc85f7d730d5b3 Mon Sep 17 00:00:00 2001 From: Louis King Date: Thu, 30 Apr 2026 00:07:49 +0100 Subject: [PATCH] Add user profiles with node adoption via /v1/adoptions endpoint Move adopt/release from profile routes to dedicated /v1/adoptions endpoint. Node API now returns adopted_by field. Profile page shows read-only adopted nodes. Node detail page has adopt/release buttons (operator adopts, admin can release any). Admin release bypasses ownership check. --- .../references/docker-source-guide.md | 5 +- .env.example | 17 +- AGENTS.md | 15 +- README.md | 6 +- ...3bf_add_user_profiles_and_user_profile_.py | 84 ++++++ docker-compose.yml | 6 +- docs/auth.md | 61 ++++- docs/i18n.md | 28 +- docs/upgrading.md | 92 ++++++- src/meshcore_hub/api/auth.py | 113 ++++++++ src/meshcore_hub/api/routes/__init__.py | 4 + src/meshcore_hub/api/routes/adoptions.py | 140 ++++++++++ src/meshcore_hub/api/routes/nodes.py | 52 +++- src/meshcore_hub/api/routes/user_profiles.py | 98 +++++++ src/meshcore_hub/common/config.py | 18 +- src/meshcore_hub/common/models/__init__.py | 4 + src/meshcore_hub/common/models/node.py | 7 + .../common/models/user_profile.py | 54 ++++ .../common/models/user_profile_node.py | 67 +++++ src/meshcore_hub/common/schemas/nodes.py | 14 + .../common/schemas/user_profiles.py | 67 +++++ src/meshcore_hub/web/app.py | 178 ++++++++++--- src/meshcore_hub/web/oidc.py | 23 +- src/meshcore_hub/web/static/js/spa/app.js | 11 +- .../web/static/js/spa/components.js | 64 ++++- .../web/static/js/spa/pages/admin/index.js | 4 +- .../web/static/js/spa/pages/admin/members.js | 4 +- .../static/js/spa/pages/admin/node-tags.js | 4 +- .../web/static/js/spa/pages/node-detail.js | 95 ++++++- .../web/static/js/spa/pages/profile.js | 110 ++++++++ src/meshcore_hub/web/static/locales/en.json | 23 +- src/meshcore_hub/web/templates/spa.html | 2 +- tests/test_api/conftest.py | 29 ++ tests/test_api/test_adoptions.py | 251 ++++++++++++++++++ tests/test_api/test_nodes.py | 28 ++ tests/test_api/test_user_profiles.py | 154 +++++++++++ tests/test_web/test_admin.py | 27 +- tests/test_web/test_error_pages.py | 2 +- tests/test_web/test_oidc.py | 62 ++--- 39 files changed, 1849 insertions(+), 174 deletions(-) create mode 100644 alembic/versions/20260429_2152_72b6578ee3bf_add_user_profiles_and_user_profile_.py create mode 100644 src/meshcore_hub/api/routes/adoptions.py create mode 100644 src/meshcore_hub/api/routes/user_profiles.py create mode 100644 src/meshcore_hub/common/models/user_profile.py create mode 100644 src/meshcore_hub/common/models/user_profile_node.py create mode 100644 src/meshcore_hub/common/schemas/user_profiles.py create mode 100644 src/meshcore_hub/web/static/js/spa/pages/profile.js create mode 100644 tests/test_api/test_adoptions.py create mode 100644 tests/test_api/test_user_profiles.py diff --git a/.agents/skills/docs-sync/references/docker-source-guide.md b/.agents/skills/docs-sync/references/docker-source-guide.md index 5bcbb0a..4d55491 100644 --- a/.agents/skills/docs-sync/references/docker-source-guide.md +++ b/.agents/skills/docs-sync/references/docker-source-guide.md @@ -200,8 +200,9 @@ Complete list from `docker-compose.yml` collector `environment:` block: | `OIDC_REDIRECT_URI` | (empty) | Auth | | `OIDC_SCOPES` | `openid email profile` | Auth | | `OIDC_ROLES_CLAIM` | `roles` | Auth | -| `OIDC_ADMIN_ROLE` | `admin` | Auth | -| `OIDC_MEMBER_ROLE` | `member` | Auth | +| `OIDC_ROLE_ADMIN` | `admin` | Auth | +| `OIDC_ROLE_OPERATOR` | `operator` | Auth | +| `OIDC_ROLE_MEMBER` | `member` | Auth | | `OIDC_SESSION_SECRET` | (empty) | Auth | | `OIDC_SESSION_MAX_AGE` | `86400` | Auth | | `OIDC_COOKIE_SECURE` | `false` | Auth | diff --git a/.env.example b/.env.example index bfc2c85..689298d 100644 --- a/.env.example +++ b/.env.example @@ -336,6 +336,11 @@ WEB_PORT=8080 # Default: 30 # WEB_AUTO_REFRESH_SECONDS=30 +# Enable debug mode in the web dashboard +# Shows extra diagnostic info (e.g., raw user IDs in the user menu) +# Default: false +# WEB_DEBUG=false + # ------------------- # OIDC Authentication # ------------------- @@ -374,13 +379,17 @@ WEB_PORT=8080 # Default: roles # OIDC_ROLES_CLAIM=roles -# Role value that grants admin access +# IdP role name that grants admin access # Default: admin -# OIDC_ADMIN_ROLE=admin +# OIDC_ROLE_ADMIN=admin -# Role value that grants member access +# IdP role name for operator access (future use) +# Default: operator +# OIDC_ROLE_OPERATOR=operator + +# IdP role name for member access # Default: member -# OIDC_MEMBER_ROLE=member +# OIDC_ROLE_MEMBER=member # Secret for signing session cookies (required when OIDC_ENABLED=true) # Generate with: openssl rand -hex 32 diff --git a/AGENTS.md b/AGENTS.md index 6c67934..f9daac2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,9 +267,12 @@ meshcore-hub/ │ │ ├── models/ # SQLAlchemy models │ │ │ ├── node.py # Node model │ │ │ ├── member.py # Network member model +│ │ │ ├── user_profile.py # User profile model (OIDC users) +│ │ │ ├── user_profile_node.py # User-node adoption join table │ │ │ └── ... │ │ └── schemas/ # Pydantic schemas │ │ ├── members.py # Member API schemas +│ │ ├── user_profiles.py # User profile API schemas │ │ └── ... │ ├── collector/ │ │ ├── cli.py # Collector CLI with seed commands @@ -289,7 +292,9 @@ meshcore-hub/ │ │ ├── metrics.py # Prometheus metrics endpoint │ │ └── routes/ # API routes │ │ ├── members.py # Member CRUD endpoints -│ │ └── ... +│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile) +│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release) +│ │ └── ... │ └── web/ │ ├── cli.py │ ├── app.py # FastAPI app @@ -646,13 +651,15 @@ Key variables: - `OIDC_POST_LOGOUT_REDIRECT_URI` - Post-logout redirect URI (must match IdP sign-out URIs, falls back to `OIDC_REDIRECT_URI` base) - `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically. When using LogTo as the OIDC provider, include `roles` in `OIDC_SCOPES` (e.g., `"openid email profile roles"`) to enable role-based admin access. - `OIDC_ROLES_CLAIM` - ID token claim for roles (default: `roles`) -- `OIDC_ADMIN_ROLE` - Role value for admin access (default: `admin`) -- `OIDC_MEMBER_ROLE` - Role value for member access (default: `member`) +- `OIDC_ROLE_ADMIN` - IdP role name for admin access (default: `admin`) +- `OIDC_ROLE_OPERATOR` - IdP role name for operator access (default: `operator`) +- `OIDC_ROLE_MEMBER` - IdP role name for member access (default: `member`) - `OIDC_SESSION_SECRET` - Secret for signing session cookies (required if OIDC_ENABLED=true) - `OIDC_SESSION_MAX_AGE` - Session lifetime in seconds (default: `86400`) - `OIDC_COOKIE_SECURE` - HTTPS-only cookies (default: `false`) - `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage. - `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable) +- `WEB_DEBUG` - Enable debug mode in the web dashboard (default: `false`) - `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`) - `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled. - `NETWORK_DOMAIN` - Network domain name (default: none) @@ -733,7 +740,7 @@ When enabled, the collector automatically deletes event data older than the rete | Variable | Description | |----------|-------------| | `NODE_CLEANUP_ENABLED` | Enable automatic cleanup of inactive nodes (default: true) | -| `NODE_CLEANUP_DAYS` | Remove nodes not seen for this many days (default: 7) | +| `NODE_CLEANUP_DAYS` | Remove nodes not seen for this many days (default: 30) | When enabled, the collector automatically removes nodes where: - `last_seen` is older than the configured number of days diff --git a/README.md b/README.md index 35b7cf2..8f3ac02 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,7 @@ The collector automatically cleans up old event data and inactive nodes: | `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) | | `WEB_DATETIME_LOCALE` | `en-US` | Locale used for date formatting in the web dashboard (e.g., `en-US` for MM/DD/YYYY, `en-GB` for DD/MM/YYYY). | | `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) | +| `WEB_DEBUG` | `false` | Enable debug mode in the web dashboard (shows extra diagnostic info) | | `OIDC_ENABLED` | `false` | Enable OIDC authentication for the web dashboard | | `OIDC_CLIENT_ID` | _(none)_ | OIDC client ID (from IdP, required when OIDC_ENABLED=true) | | `OIDC_CLIENT_SECRET` | _(none)_ | OIDC client secret (from IdP, required when OIDC_ENABLED=true) | @@ -381,8 +382,9 @@ The collector automatically cleans up old event data and inactive nodes: | `OIDC_POST_LOGOUT_REDIRECT_URI` | _(auto-derived)_ | Post-logout redirect URI (must match Sign-out redirect URIs in IdP). Falls back to `OIDC_REDIRECT_URI` base or `request.base_url` | | `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request. The `openid` scope is required for ID tokens. Quotes are stripped automatically. | | `OIDC_ROLES_CLAIM` | `roles` | ID token claim name containing user roles | -| `OIDC_ADMIN_ROLE` | `admin` | Role value granting admin access | -| `OIDC_MEMBER_ROLE` | `member` | Role value granting member access | +| `OIDC_ROLE_ADMIN` | `admin` | IdP role name granting admin access | +| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access (future use) | +| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access | | `OIDC_SESSION_SECRET` | _(none)_ | Secret for signing session cookies (required when OIDC_ENABLED=true) | | `OIDC_SESSION_MAX_AGE` | `86400` | Session cookie lifetime in seconds (default 24 hours) | | `OIDC_COOKIE_SECURE` | `false` | HTTPS-only session cookies (enable in production) | diff --git a/alembic/versions/20260429_2152_72b6578ee3bf_add_user_profiles_and_user_profile_.py b/alembic/versions/20260429_2152_72b6578ee3bf_add_user_profiles_and_user_profile_.py new file mode 100644 index 0000000..67181e1 --- /dev/null +++ b/alembic/versions/20260429_2152_72b6578ee3bf_add_user_profiles_and_user_profile_.py @@ -0,0 +1,84 @@ +"""add user_profiles and user_profile_nodes tables + +Revision ID: 72b6578ee3bf +Revises: a10dbca883a2 +Create Date: 2026-04-29 21:52:04.028351+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "72b6578ee3bf" +down_revision: Union[str, None] = "a10dbca883a2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_profiles", + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column("callsign", sa.String(length=20), nullable=True), + sa.Column("id", sa.String(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("user_profiles", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_user_profiles_user_id"), ["user_id"], unique=True + ) + + op.create_table( + "user_profile_nodes", + sa.Column("user_profile_id", sa.String(), nullable=False), + sa.Column("node_id", sa.String(), nullable=False), + sa.Column("adopted_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["node_id"], + ["nodes.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_profile_id"], + ["user_profiles.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("user_profile_id", "node_id"), + sa.UniqueConstraint("node_id", name="uq_user_profile_nodes_node_id"), + ) + with op.batch_alter_table("user_profile_nodes", schema=None) as batch_op: + batch_op.create_index( + "ix_user_profile_nodes_node_id", ["node_id"], unique=False + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user_profile_nodes", schema=None) as batch_op: + batch_op.drop_index("ix_user_profile_nodes_node_id") + + op.drop_table("user_profile_nodes") + with op.batch_alter_table("user_profiles", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_user_profiles_user_id")) + + op.drop_table("user_profiles") + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index 5c15e59..02df9c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -265,6 +265,7 @@ services: - WEB_THEME=${WEB_THEME:-dark} - WEB_LOCALE=${WEB_LOCALE:-en} - WEB_DATETIME_LOCALE=${WEB_DATETIME_LOCALE:-en-US} + - WEB_DEBUG=${WEB_DEBUG:-false} # OIDC authentication (see .env.example for details) - OIDC_ENABLED=${OIDC_ENABLED:-false} - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} @@ -273,8 +274,9 @@ services: - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} - OIDC_SCOPES=${OIDC_SCOPES:-openid email profile} - OIDC_ROLES_CLAIM=${OIDC_ROLES_CLAIM:-roles} - - OIDC_ADMIN_ROLE=${OIDC_ADMIN_ROLE:-admin} - - OIDC_MEMBER_ROLE=${OIDC_MEMBER_ROLE:-member} + - OIDC_ROLE_ADMIN=${OIDC_ROLE_ADMIN:-admin} + - OIDC_ROLE_OPERATOR=${OIDC_ROLE_OPERATOR:-operator} + - OIDC_ROLE_MEMBER=${OIDC_ROLE_MEMBER:-member} - OIDC_SESSION_SECRET=${OIDC_SESSION_SECRET:-} - OIDC_SESSION_MAX_AGE=${OIDC_SESSION_MAX_AGE:-86400} - OIDC_COOKIE_SECURE=${OIDC_COOKIE_SECURE:-false} diff --git a/docs/auth.md b/docs/auth.md index 10c379b..c75107a 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -1,26 +1,68 @@ # OIDC Authentication -MeshCore Hub supports OpenID Connect (OIDC) for authenticating web dashboard users. When enabled, the admin interface (`/admin/`) requires an authenticated session with the `admin` role. +MeshCore Hub supports OpenID Connect (OIDC) for authenticating web dashboard users. When enabled, the web dashboard uses role-based access control to gate API endpoints through the proxy layer. -When OIDC is **disabled** (the default), no admin functionality is exposed — the admin link is hidden from the navbar and admin routes are not registered. +When OIDC is **disabled** (the default), the web proxy only allows read (GET) access to API endpoints. Write operations (POST/PUT/DELETE) and unknown endpoints are blocked. Admin operations must be performed via the CLI or direct API access with Bearer tokens. ## Architecture -OIDC is implemented entirely in the web dashboard layer. The REST API uses static Bearer token authentication independently of OIDC. The web dashboard proxies API requests and gates write operations (POST, PUT, DELETE, PATCH) by checking the user's session role. +OIDC is implemented entirely in the web dashboard layer. The REST API uses static Bearer token authentication independently of OIDC. The web dashboard proxies API requests and gates access using a per-endpoint, per-method role mapping (`ENDPOINT_ACCESS` in `web/app.py`). ``` Browser → Web Dashboard (OIDC session + API proxy) → REST API (Bearer token) ``` +### Role-Based Access + +User roles are read from the OIDC token's `roles` claim (configurable via `OIDC_ROLES_CLAIM`). The web proxy checks these roles against the `ENDPOINT_ACCESS` mapping to determine which API endpoints and HTTP methods each user can access. + +**Defined roles:** + +| 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` | Reserved for future use — no endpoint assignments yet | +| 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`. + +Additional roles can be added to the `ENDPOINT_ACCESS` mapping in `src/meshcore_hub/web/app.py` and the corresponding `OIDC_ROLE_*` config variable. + +### Endpoint Access Mapping + +The proxy uses a hardcoded per-endpoint, per-method mapping in `src/meshcore_hub/web/app.py`: + +| Path prefix | Method | Access | +|-------------|--------|--------| +| `v1/nodes` | GET | Open | +| `v1/nodes/` | GET | Open | +| `v1/nodes/` | POST, PUT, DELETE | `admin` | +| `v1/members` | GET | Open | +| `v1/members` | POST, PUT, DELETE | `admin` | +| `v1/messages` | GET | Open | +| `v1/advertisements` | GET | Open | +| `v1/dashboard` | GET | Open | +| `v1/trace-paths` | GET | Open | +| `v1/telemetry` | GET | Open | + +- **Open** = no authentication required (anonymous OK, works with or without OIDC) +- **`admin`** = requires OIDC enabled + user has the `admin` role +- Method not listed for a matched prefix = denied +- No prefix match = denied + +### Client-Side Role Checks + +The SPA receives the user's roles array in `window.__APP_CONFIG__.roles`. Client-side pages use the `hasRole(roleName)` helper, which returns `true` when OIDC is disabled (open access) or when the user has the specified role. + ## Login Flow 1. User visits `/admin/` — no session exists — server redirects to `/auth/login?next=/admin/` 2. `/auth/login` stores the `next` URL in the session and redirects to the IdP 3. User authenticates at the IdP, which redirects back to `/auth/callback?code=...` 4. `/auth/callback` exchanges the authorization code for tokens, extracts userinfo and roles, stores them in a session cookie, and redirects to the `next` URL -5. The SPA receives `window.__APP_CONFIG__` with `oidc_enabled`, `user`, `is_member`, and `is_admin` flags -6. Admin routes are registered client-side only when `is_admin` is `true` -7. Write operations through the API proxy are blocked for non-admin sessions (HTTP 403) +5. The SPA receives `window.__APP_CONFIG__` with `oidc_enabled`, `user`, and `roles` flags +6. Admin routes are registered client-side only when `hasRole('admin')` is `true` +7. Write operations through the API proxy are checked against the `ENDPOINT_ACCESS` mapping 8. Logout (`/auth/logout`) clears the session and redirects to the IdP's end-session endpoint ## Configuration @@ -37,8 +79,9 @@ All OIDC settings are environment variables. Set `OIDC_ENABLED=true` to activate | `OIDC_POST_LOGOUT_REDIRECT_URI` | _(auto)_ | Post-logout redirect URI (falls back to `OIDC_REDIRECT_URI` base or request URL) | | `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request. The `openid` scope is required. Quotes are stripped automatically. | | `OIDC_ROLES_CLAIM` | `roles` | ID token claim name containing user roles | -| `OIDC_ADMIN_ROLE` | `admin` | Role value that grants admin access | -| `OIDC_MEMBER_ROLE` | `member` | Role value that grants member access | +| `OIDC_ROLE_ADMIN` | `admin` | IdP role name that grants admin access | +| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access (future use) | +| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access | | `OIDC_SESSION_SECRET` | _(none)_ | Secret for signing session cookies (generate with `openssl rand -hex 32`) | | `OIDC_SESSION_MAX_AGE` | `86400` | Session cookie lifetime in seconds (default: 24 hours) | | `OIDC_COOKIE_SECURE` | `false` | Set to `true` to require HTTPS for session cookies | @@ -79,4 +122,4 @@ OIDC_SCOPES="openid email profile roles" **Role assignment:** -Create `admin` and/or `member` roles in LogTo and assign them to users. The role names must match `OIDC_ADMIN_ROLE` and `OIDC_MEMBER_ROLE` (both default to `admin` and `member` respectively). +Create an `admin` role in LogTo and assign it to users who should have write access through the web dashboard. The role name must match `OIDC_ROLE_ADMIN` (default: `admin`). If your LogTo setup uses a different role name, set `OIDC_ROLE_ADMIN` accordingly. diff --git a/docs/i18n.md b/docs/i18n.md index 2602dd3..ea61357 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -265,11 +265,19 @@ Dashboard page content: ### 9. `nodes` -Node-specific labels: +Node detail page labels: | Key | English | Context | |-----|---------|---------| | `scan_to_add` | Scan to add as contact | QR code instruction | +| `ownership` | Ownership | Adoption card heading on node detail page | +| `adopt` | Adopt | Adopt button (operator/admin only) | +| `release` | Release | Release button (owner or admin only) | +| `adopted_by` | Adopted by {{name}} | Display name of adopting user ({{name}} = user name or ID) | +| `not_adopted` | This node has not been adopted by any operator. | Shown when node is unadopted and user is operator/admin | +| `adopt_success` | Node adopted successfully | Flash message after adopting | +| `release_success` | Node released successfully | Flash message after releasing | +| `release_confirm` | Are you sure you want to release this node? | Confirmation dialog for release | ### 10. `advertisements` @@ -405,6 +413,24 @@ Footer content: |-----|---------|---------| | `powered_by` | Powered by | "Powered by" attribution | +### 20. `user_profile` + +User profile page (OIDC authenticated users): + +| Key | English | Context | +|-----|---------|---------| +| `title` | Profile | Page heading | +| `your_profile` | Your Profile | Profile card heading | +| `profile_updated` | Profile updated successfully | Flash message after save | +| `save_profile` | Save Profile | Save button label | +| `name_label` | Display Name | Form label | +| `name_placeholder` | Your name or preferred name | Input placeholder | +| `callsign_label` | Callsign | Form label | +| `callsign_placeholder` | Amateur radio callsign (e.g., W1ABC) | Input placeholder | +| `adopted_nodes` | Adopted Nodes | Adopted nodes card heading | +| `no_adopted_nodes` | No adopted nodes | Empty state | +| `login_to_view` | Log in to view your profile | Unauthenticated notice | + ## Translation Tips 1. **Preserve HTML tags:** Some strings contain ``, ``, or `
` tags - keep these intact. diff --git a/docs/upgrading.md b/docs/upgrading.md index 1dd2dd5..3b6749b 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -4,7 +4,7 @@ This guide covers upgrading from a previous MeshCore Hub release to the current ## v0.10.0 -This release includes **breaking changes** to the admin authentication model. +This release includes **breaking changes** to the admin authentication model, OIDC role configuration, and adds user profiles with node adoption. ### Overview of Changes @@ -12,9 +12,18 @@ This release includes **breaking changes** to the admin authentication model. |------|--------|-------| | Admin auth | `WEB_ADMIN_ENABLED=true` (open access) | OIDC/OAuth2 authentication via identity provider | | Auth library | None | Authlib (`authlib>=1.3.0`) | -| Admin access | Anyone with the URL | Authenticated users with `admin` role from IdP | +| Admin access | Anyone with the URL | Authenticated users with roles from IdP | | Session mgmt | None | Starlette `SessionMiddleware` (signed cookies) | -| Write gating | None (API proxy open) | POST/PUT/DELETE/PATCH require admin session when OIDC enabled | +| Proxy gating | None (API proxy open) | Per-endpoint, per-method role-based access mapping | +| Role config | None | `OIDC_ROLE_ADMIN`, `OIDC_ROLE_OPERATOR`, `OIDC_ROLE_MEMBER` env vars | +| SPA config | `is_admin: bool`, `is_member: bool` | `roles: ["admin", "member"]` array + `role_names` mapping | +| Client-side | `config.is_admin` checks | `hasRole("admin")` helper | +| OIDC disabled | All proxy access open | Only read access to known endpoints; writes blocked | +| User profiles | None | `user_profiles` table (auto-created on first access) | +| Node adoption | None | `user_profile_nodes` join table (operator role required) | +| Node cleanup | 7 days default | 30 days default | +| API proxy | No user identity forwarding | Injects `X-User-Id` and `X-User-Roles` headers | +| Profile page | None | `/profile` SPA page linked from auth dropdown | ### Migration Steps @@ -28,7 +37,15 @@ This release includes **breaking changes** to the admin authentication model. OIDC_SESSION_SECRET=$(openssl rand -hex 32) ``` 3. **Remove `WEB_ADMIN_ENABLED`** from your `.env` (no longer used) -4. **Configure roles** in your IdP: assign `admin` and/or `member` roles to users +4. **Remove `OIDC_ADMIN_ROLE` and `OIDC_MEMBER_ROLE`** from your `.env` if present (renamed, see below) +5. **Configure roles** in your IdP and set the role name env vars to match: + ```bash + # These defaults match common IdP setups — only change if your IdP uses different role names + OIDC_ROLE_ADMIN=admin + OIDC_ROLE_OPERATOR=operator + OIDC_ROLE_MEMBER=member + ``` +6. **Test admin access** — confirm that admin users can access `/admin/` and perform write operations ### Removed Variables @@ -36,12 +53,79 @@ This release includes **breaking changes** to the admin authentication model. |----------|--------| | `WEB_ADMIN_ENABLED` | Replaced by `OIDC_ENABLED` | +### Renamed Variables + +| Old Variable | New Variable | Notes | +|--------------|-------------|-------| +| `OIDC_ADMIN_ROLE` | `OIDC_ROLE_ADMIN` | New naming convention (`OIDC_ROLE_`) | +| `OIDC_MEMBER_ROLE` | `OIDC_ROLE_MEMBER` | New naming convention | + ### New Variables +| Variable | Default | Description | +|----------|---------|-------------| +| `OIDC_ROLE_ADMIN` | `admin` | IdP role name granting admin access | +| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access (future use) | +| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access | + See the OIDC section in `.env.example` for the full list of environment variables. +### Behavior Change: OIDC Disabled + +When OIDC is disabled, the web proxy now only allows GET access to known API endpoints. Write operations (POST/PUT/DELETE) are blocked, even without OIDC. If you relied on open write access through the web proxy without OIDC, use the CLI or direct API access with Bearer tokens instead. + **Important for LogTo users:** You must pass `client_id` in the logout request for the post-logout redirect to work. This is handled automatically by the application. You also need to register your app's URL as a **Sign-out redirect URI** in the LogTo admin console (e.g. `https://ipnt.uk`). If the redirect still doesn't work after updating, set `OIDC_POST_LOGOUT_REDIRECT_URI` explicitly to match your registered URI. +### User Profiles and Node Adoption + +Authenticated OIDC users now have a profile page at `/profile` (linked from the avatar dropdown menu). Profiles are auto-created on first access with blank name and callsign fields. + +Users with the **operator** role can adopt (claim) mesh network nodes from the node detail page. Users with the **admin** role can release any adopted node. Adopted nodes are shown as a read-only list on the profile page and display the adopting user's name on the node detail page. + +### New API Endpoints + +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/api/v1/user/profile/{user_id}` | GET | Any OIDC user (own profile only) | Get-or-create profile with adopted nodes | +| `/api/v1/user/profile/{user_id}` | PUT | Any OIDC user (own profile only) | Update name/callsign | +| `/api/v1/adoptions` | POST | Operator or Admin | Adopt a node (auto-creates profile if needed) | +| `/api/v1/adoptions/{public_key}` | DELETE | Operator (own node) or Admin (any) | Release a node | + +The `NodeRead` schema now includes an `adopted_by` field with the adopting user's `user_id`, `name`, and `callsign` (or `null` if not adopted). + +The web proxy injects `X-User-Id` and `X-User-Roles` headers when forwarding API requests for authenticated users, enabling the API layer to enforce per-user access control. + +### New Database Tables + +The database migration creates two new tables: + +**`user_profiles`**: Stores OIDC user profile data (auto-created on first access). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | VARCHAR(36) PK | UUID | +| `user_id` | VARCHAR(255) UNIQUE | OIDC `sub` claim | +| `name` | VARCHAR(255) | Display name (blank initially) | +| `callsign` | VARCHAR(20) | Radio callsign (blank initially) | +| `created_at` | DATETIME | Auto | +| `updated_at` | DATETIME | Auto | + +**`user_profile_nodes`**: Join table linking users to adopted nodes. Foreign keys have `ON DELETE CASCADE` so node cleanup automatically removes stale adoption records. + +| Column | Type | Description | +|--------|------|-------------| +| `user_profile_id` | VARCHAR(36) FK PK | References `user_profiles.id` (CASCADE) | +| `node_id` | VARCHAR(36) FK PK UNIQUE | References `nodes.id` (CASCADE) | +| `adopted_at` | DATETIME | When the adoption occurred | + +### Default Change: Node Cleanup + +The default value for `NODE_CLEANUP_DAYS` has changed from **7 days** to **30 days**. If you previously relied on the 7-day default, set it explicitly in your `.env`: + +```bash +NODE_CLEANUP_DAYS=7 +``` + ## v0.9.0 This release includes **breaking changes** to the MQTT broker, packet capture service, data ingestion pipeline, and public key handling. diff --git a/src/meshcore_hub/api/auth.py b/src/meshcore_hub/api/auth.py index a64ca61..ba48721 100644 --- a/src/meshcore_hub/api/auth.py +++ b/src/meshcore_hub/api/auth.py @@ -12,6 +12,10 @@ logger = logging.getLogger(__name__) # Security scheme security = HTTPBearer(auto_error=False) +# Header constants for proxy-injected user identity +X_USER_ID_HEADER = "X-User-Id" +X_USER_ROLES_HEADER = "X-User-Roles" + def get_api_keys(request: Request) -> tuple[str | None, str | None]: """Get API keys from app state. @@ -139,3 +143,112 @@ async def require_admin( # Dependency types for use in routes RequireRead = Annotated[str | None, Depends(require_read)] RequireAdmin = Annotated[str, Depends(require_admin)] + + +async def require_user_owner( + request: Request, + token: Annotated[str | None, Depends(get_current_token)], +) -> str: + """Require an authenticated user identity via X-User-Id header. + + The web proxy injects X-User-Id when an OIDC user is authenticated. + The header is trusted because only the proxy has the API key. + + Returns: + The user_id string from the X-User-Id header. + + Raises: + HTTPException: 401 if no valid API key or no X-User-Id header. + """ + read_key, admin_key = get_api_keys(request) + + if read_key or admin_key: + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + key_valid = (read_key and hmac.compare_digest(token, read_key)) or ( + admin_key and hmac.compare_digest(token, admin_key) + ) + if not key_valid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = request.headers.get(X_USER_ID_HEADER) + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User identity required", + ) + + return user_id + + +async def require_operator( + request: Request, + token: Annotated[str | None, Depends(get_current_token)], +) -> tuple[str, list[str]]: + """Require an authenticated user with the operator role. + + Checks X-User-Roles header (comma-separated) for the operator role. + Also validates API key and X-User-Id header. + + Returns: + Tuple of (user_id, roles_list). + + Raises: + HTTPException: 401 if not authenticated, 403 if not operator. + """ + user_id = await require_user_owner(request, token) + + roles_header = request.headers.get(X_USER_ROLES_HEADER, "") + roles = [r.strip() for r in roles_header.split(",") if r.strip()] + + operator_role = getattr(request.app.state, "oidc_role_operator", "operator") + if operator_role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operator role required", + ) + + return user_id, roles + + +async def require_operator_or_admin( + request: Request, + token: Annotated[str | None, Depends(get_current_token)], +) -> tuple[str, list[str]]: + """Require an authenticated user with operator or admin role. + + Returns: + Tuple of (user_id, roles_list). + + Raises: + HTTPException: 401 if not authenticated, 403 if not operator or admin. + """ + user_id = await require_user_owner(request, token) + + roles_header = request.headers.get(X_USER_ROLES_HEADER, "") + roles = [r.strip() for r in roles_header.split(",") if r.strip()] + + operator_role = getattr(request.app.state, "oidc_role_operator", "operator") + admin_role = getattr(request.app.state, "oidc_role_admin", "admin") + if operator_role not in roles and admin_role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operator or admin role required", + ) + + return user_id, roles + + +RequireUserOwner = Annotated[str, Depends(require_user_owner)] +RequireOperator = Annotated[tuple[str, list[str]], Depends(require_operator)] +RequireOperatorOrAdmin = Annotated[ + tuple[str, list[str]], Depends(require_operator_or_admin) +] diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py index 771b5b3..b7f26b7 100644 --- a/src/meshcore_hub/api/routes/__init__.py +++ b/src/meshcore_hub/api/routes/__init__.py @@ -10,6 +10,8 @@ from meshcore_hub.api.routes.trace_paths import router as trace_paths_router from meshcore_hub.api.routes.telemetry import router as telemetry_router from meshcore_hub.api.routes.dashboard import router as dashboard_router from meshcore_hub.api.routes.members import router as members_router +from meshcore_hub.api.routes.user_profiles import router as user_profiles_router +from meshcore_hub.api.routes.adoptions import router as adoptions_router api_router = APIRouter() @@ -26,3 +28,5 @@ api_router.include_router( api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"]) api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"]) api_router.include_router(members_router, prefix="/members", tags=["Members"]) +api_router.include_router(user_profiles_router, prefix="/user", tags=["User"]) +api_router.include_router(adoptions_router, prefix="/adoptions", tags=["Adoptions"]) diff --git a/src/meshcore_hub/api/routes/adoptions.py b/src/meshcore_hub/api/routes/adoptions.py new file mode 100644 index 0000000..38de3f8 --- /dev/null +++ b/src/meshcore_hub/api/routes/adoptions.py @@ -0,0 +1,140 @@ +"""Node adoption API routes.""" + +import logging + +from fastapi import APIRouter, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from meshcore_hub.api.auth import RequireOperatorOrAdmin +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Node, UserProfile, UserProfileNode +from meshcore_hub.common.schemas.user_profiles import AdoptedNodeRead, NodeAdoptRequest + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _get_or_create_profile(session: DbSession, user_id: str) -> UserProfile: + """Get existing profile or create a new blank one.""" + query = select(UserProfile).where(UserProfile.user_id == user_id) + profile = session.execute(query).scalar_one_or_none() + if profile: + return profile + + profile = UserProfile(user_id=user_id) + session.add(profile) + session.commit() + session.refresh(profile) + logger.info("Created new user profile for user_id=%s", user_id) + return profile + + +@router.post("", response_model=AdoptedNodeRead, status_code=201) +async def adopt_node( + adopt_request: NodeAdoptRequest, + caller_info: RequireOperatorOrAdmin, + session: DbSession, +) -> AdoptedNodeRead: + """Adopt a node. Requires operator or admin role.""" + caller_id, _ = caller_info + profile = _get_or_create_profile(session, caller_id) + + public_key = adopt_request.public_key.lower() + + node_query = select(Node).where(Node.public_key == public_key) + node = session.execute(node_query).scalar_one_or_none() + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Node with public_key '{public_key}' not found", + ) + + existing_query = select(UserProfileNode).where(UserProfileNode.node_id == node.id) + existing = session.execute(existing_query).scalar_one_or_none() + if existing: + if existing.user_profile_id == profile.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Node already adopted by this user", + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Node already adopted by another user", + ) + + association = UserProfileNode( + user_profile_id=profile.id, + node_id=node.id, + ) + session.add(association) + session.commit() + session.refresh(association) + + logger.info( + "User %s adopted node %s", + caller_id, + public_key[:12], + ) + + return AdoptedNodeRead( + public_key=node.public_key, + name=node.name, + adv_type=node.adv_type, + adopted_at=association.adopted_at, + ) + + +@router.delete("/{public_key}", status_code=204) +async def release_node( + public_key: str, + caller_info: RequireOperatorOrAdmin, + request: Request, + session: DbSession, +) -> None: + """Release (unadopt) a node. + + Operators can only release their own adopted nodes. + Admins can release any node. + """ + caller_id, roles = caller_info + admin_role = getattr(request.app.state, "oidc_role_admin", "admin") + is_admin = admin_role in roles + + normalized_key = public_key.lower() + + node_query = select(Node).where(Node.public_key == normalized_key) + node = session.execute(node_query).scalar_one_or_none() + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Node with public_key '{public_key}' not found", + ) + + assoc_query = ( + select(UserProfileNode) + .where(UserProfileNode.node_id == node.id) + .options(selectinload(UserProfileNode.user_profile)) + ) + association = session.execute(assoc_query).scalar_one_or_none() + if not association: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Node is not adopted", + ) + + if not is_admin and association.user_profile.user_id != caller_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the adopting user or an admin can release this node", + ) + + session.delete(association) + session.commit() + + logger.info( + "User %s released node %s", + caller_id, + normalized_key[:12], + ) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index d3ebdd9..febfd95 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -8,12 +8,31 @@ from sqlalchemy.orm import selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import Node, NodeTag -from meshcore_hub.common.schemas.nodes import NodeList, NodeRead +from meshcore_hub.common.models import Node, NodeTag, UserProfileNode +from meshcore_hub.common.schemas.nodes import AdoptedByUser, NodeList, NodeRead router = APIRouter() +def _get_adopted_by(node: Node) -> Optional[AdoptedByUser]: + """Extract adopted_by info from a node's eager-loaded associations.""" + if node.user_profile_associations: + profile = node.user_profile_associations[0].user_profile + return AdoptedByUser( + user_id=profile.user_id, + name=profile.name, + callsign=profile.callsign, + ) + return None + + +def _node_to_read(node: Node) -> NodeRead: + """Convert a Node ORM object to NodeRead schema with adopted_by.""" + node_read = NodeRead.model_validate(node) + node_read.adopted_by = _get_adopted_by(node) + return node_read + + @router.get("", response_model=NodeList) async def list_nodes( _: RequireRead, @@ -28,8 +47,13 @@ async def list_nodes( offset: int = Query(0, ge=0, description="Page offset"), ) -> NodeList: """List all nodes with pagination and filtering.""" - # Build base query with tags loaded - query = select(Node).options(selectinload(Node.tags)) + # Build base query with tags and adoption info loaded + query = select(Node).options( + selectinload(Node.tags), + selectinload(Node.user_profile_associations).selectinload( + UserProfileNode.user_profile + ), + ) if search: # Search in public key, node name, or name tag @@ -113,7 +137,7 @@ async def list_nodes( nodes = session.execute(query).scalars().all() return NodeList( - items=[NodeRead.model_validate(n) for n in nodes], + items=[_node_to_read(n) for n in nodes], total=total, limit=limit, offset=offset, @@ -132,7 +156,12 @@ async def get_node_by_prefix( """ query = ( select(Node) - .options(selectinload(Node.tags)) + .options( + selectinload(Node.tags), + selectinload(Node.user_profile_associations).selectinload( + UserProfileNode.user_profile + ), + ) .where(Node.public_key.startswith(prefix)) .order_by(Node.public_key) .limit(1) @@ -142,7 +171,7 @@ async def get_node_by_prefix( if not node: raise HTTPException(status_code=404, detail="Node not found") - return NodeRead.model_validate(node) + return _node_to_read(node) @router.get("/{public_key}", response_model=NodeRead) @@ -154,7 +183,12 @@ async def get_node( """Get a single node by exact public key match.""" query = ( select(Node) - .options(selectinload(Node.tags)) + .options( + selectinload(Node.tags), + selectinload(Node.user_profile_associations).selectinload( + UserProfileNode.user_profile + ), + ) .where(Node.public_key == public_key) ) node = session.execute(query).scalar_one_or_none() @@ -162,4 +196,4 @@ async def get_node( if not node: raise HTTPException(status_code=404, detail="Node not found") - return NodeRead.model_validate(node) + return _node_to_read(node) diff --git a/src/meshcore_hub/api/routes/user_profiles.py b/src/meshcore_hub/api/routes/user_profiles.py new file mode 100644 index 0000000..cc448e7 --- /dev/null +++ b/src/meshcore_hub/api/routes/user_profiles.py @@ -0,0 +1,98 @@ +"""User profile API routes.""" + +import logging + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import select + +from meshcore_hub.api.auth import RequireUserOwner +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import UserProfile +from meshcore_hub.common.schemas.user_profiles import ( + AdoptedNodeRead, + UserProfileRead, + UserProfileUpdate, + UserProfileWithNodes, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _verify_owner(user_id: str, requested_id: str) -> None: + """Verify the authenticated user matches the requested user_id.""" + if user_id != requested_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: cannot access another user's profile", + ) + + +def _get_or_create_profile(session: DbSession, user_id: str) -> UserProfile: + """Get existing profile or create a new blank one.""" + query = select(UserProfile).where(UserProfile.user_id == user_id) + profile = session.execute(query).scalar_one_or_none() + if profile: + return profile + + profile = UserProfile(user_id=user_id) + session.add(profile) + session.commit() + session.refresh(profile) + logger.info("Created new user profile for user_id=%s", user_id) + return profile + + +@router.get("/profile/{user_id}", response_model=UserProfileWithNodes) +async def get_profile( + user_id: str, + caller_id: RequireUserOwner, + session: DbSession, +) -> UserProfileWithNodes: + """Get or create a user profile. Auto-creates on first access.""" + _verify_owner(caller_id, user_id) + profile = _get_or_create_profile(session, user_id) + + adopted_nodes = [] + for assoc in profile.node_associations: + adopted_nodes.append( + AdoptedNodeRead( + public_key=assoc.node.public_key, + name=assoc.node.name, + adv_type=assoc.node.adv_type, + adopted_at=assoc.adopted_at, + ) + ) + + return UserProfileWithNodes( + id=profile.id, + user_id=profile.user_id, + name=profile.name, + callsign=profile.callsign, + created_at=profile.created_at, + updated_at=profile.updated_at, + nodes=adopted_nodes, + ) + + +@router.put("/profile/{user_id}", response_model=UserProfileRead) +async def update_profile( + user_id: str, + profile_update: UserProfileUpdate, + caller_id: RequireUserOwner, + session: DbSession, +) -> UserProfileRead: + """Update a user profile.""" + _verify_owner(caller_id, user_id) + profile = _get_or_create_profile(session, user_id) + + if profile_update.name is not None: + profile.name = profile_update.name + if profile_update.callsign is not None: + profile.callsign = profile_update.callsign + + session.commit() + session.refresh(profile) + + return UserProfileRead.model_validate(profile) diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index f6160ab..55b6afa 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -134,7 +134,7 @@ class CollectorSettings(CommonSettings): default=True, description="Enable automatic cleanup of inactive nodes" ) node_cleanup_days: int = Field( - default=7, + default=30, description="Remove nodes not seen for this many days (last_seen)", ge=1, ) @@ -277,7 +277,10 @@ class WebSettings(CommonSettings): web_auto_refresh_seconds: int = Field( default=30, description="Auto-refresh interval in seconds for list pages (0 to disable)", - ge=0, + ) + web_debug: bool = Field( + default=False, + description="Enable debug mode in the web dashboard", ) # OIDC / OAuth2 authentication @@ -306,11 +309,14 @@ class WebSettings(CommonSettings): oidc_roles_claim: str = Field( default="roles", description="ID token claim containing user roles" ) - oidc_admin_role: str = Field( - default="admin", description="Role value granting admin access" + oidc_role_admin: str = Field( + default="admin", description="IdP role name for admin access" ) - oidc_member_role: str = Field( - default="member", description="Role value granting member access" + oidc_role_operator: str = Field( + default="operator", description="IdP role name for operator access" + ) + oidc_role_member: str = Field( + default="member", description="IdP role name for member access" ) oidc_session_secret: Optional[str] = Field( default=None, description="Secret key for signing session cookies" diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index 8fcb3e9..4872e09 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -9,6 +9,8 @@ from meshcore_hub.common.models.trace_path import TracePath from meshcore_hub.common.models.telemetry import Telemetry from meshcore_hub.common.models.event_log import EventLog from meshcore_hub.common.models.member import Member +from meshcore_hub.common.models.user_profile import UserProfile +from meshcore_hub.common.models.user_profile_node import UserProfileNode from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer __all__ = [ @@ -22,6 +24,8 @@ __all__ = [ "Telemetry", "EventLog", "Member", + "UserProfile", + "UserProfileNode", "EventObserver", "add_event_observer", ] diff --git a/src/meshcore_hub/common/models/node.py b/src/meshcore_hub/common/models/node.py index f4d7987..a920aa1 100644 --- a/src/meshcore_hub/common/models/node.py +++ b/src/meshcore_hub/common/models/node.py @@ -10,6 +10,7 @@ from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc if TYPE_CHECKING: from meshcore_hub.common.models.node_tag import NodeTag + from meshcore_hub.common.models.user_profile_node import UserProfileNode class Node(Base, UUIDMixin, TimestampMixin): @@ -81,6 +82,12 @@ class Node(Base, UUIDMixin, TimestampMixin): cascade="all, delete-orphan", lazy="selectin", ) + user_profile_associations: Mapped[list["UserProfileNode"]] = relationship( + "UserProfileNode", + back_populates="node", + cascade="all, delete-orphan", + lazy="selectin", + ) __table_args__ = ( Index("ix_nodes_last_seen", "last_seen"), diff --git a/src/meshcore_hub/common/models/user_profile.py b/src/meshcore_hub/common/models/user_profile.py new file mode 100644 index 0000000..5ec284e --- /dev/null +++ b/src/meshcore_hub/common/models/user_profile.py @@ -0,0 +1,54 @@ +"""UserProfile model for authenticated user profiles.""" + +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from meshcore_hub.common.models.user_profile_node import UserProfileNode + + +class UserProfile(Base, UUIDMixin, TimestampMixin): + """UserProfile model for authenticated OIDC users. + + Stores profile information for users who authenticate via OIDC. + Profiles are auto-created lazily on first access. + + Attributes: + id: UUID primary key + user_id: OIDC subject identifier (unique, from IdP 'sub' claim) + name: User's display name or preferred name (blank initially) + callsign: Amateur radio callsign (blank initially) + created_at: Record creation timestamp + updated_at: Record update timestamp + """ + + __tablename__ = "user_profiles" + + user_id: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + ) + name: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + ) + callsign: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + ) + + node_associations: Mapped[list["UserProfileNode"]] = relationship( + "UserProfileNode", + back_populates="user_profile", + cascade="all, delete-orphan", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/user_profile_node.py b/src/meshcore_hub/common/models/user_profile_node.py new file mode 100644 index 0000000..87b9253 --- /dev/null +++ b/src/meshcore_hub/common/models/user_profile_node.py @@ -0,0 +1,67 @@ +"""UserProfileNode association model for user-to-node adoption.""" + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, Index, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from meshcore_hub.common.models.base import Base + +if TYPE_CHECKING: + from meshcore_hub.common.models.node import Node + from meshcore_hub.common.models.user_profile import UserProfile + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class UserProfileNode(Base): + """Association table linking UserProfiles to Nodes (adoption). + + A node can be adopted by at most one user (enforced by unique constraint + on node_id). A user can adopt zero or more nodes. + + Attributes: + user_profile_id: FK to user_profiles.id (part of composite PK) + node_id: FK to nodes.id (part of composite PK, also unique) + adopted_at: Timestamp when the adoption occurred + """ + + __tablename__ = "user_profile_nodes" + + user_profile_id: Mapped[str] = mapped_column( + ForeignKey("user_profiles.id", ondelete="CASCADE"), + primary_key=True, + ) + node_id: Mapped[str] = mapped_column( + ForeignKey("nodes.id", ondelete="CASCADE"), + primary_key=True, + ) + adopted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=_utc_now, + nullable=False, + ) + + user_profile: Mapped["UserProfile"] = relationship( + "UserProfile", + back_populates="node_associations", + ) + node: Mapped["Node"] = relationship( + "Node", + back_populates="user_profile_associations", + ) + + __table_args__ = ( + UniqueConstraint("node_id", name="uq_user_profile_nodes_node_id"), + Index("ix_user_profile_nodes_node_id", "node_id"), + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py index 8f05575..3f0ef87 100644 --- a/src/meshcore_hub/common/schemas/nodes.py +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -72,6 +72,17 @@ class NodeTagRead(BaseModel): from_attributes = True +class AdoptedByUser(BaseModel): + """Schema for the user who has adopted a node.""" + + user_id: str = Field(..., description="OIDC subject identifier") + name: Optional[str] = Field(default=None, description="User display name") + callsign: Optional[str] = Field(default=None, description="Amateur radio callsign") + + class Config: + from_attributes = True + + class NodeRead(BaseModel): """Schema for reading a node.""" @@ -88,6 +99,9 @@ class NodeRead(BaseModel): created_at: datetime = Field(..., description="Record creation timestamp") updated_at: datetime = Field(..., description="Record update timestamp") tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags") + adopted_by: Optional[AdoptedByUser] = Field( + default=None, description="User who has adopted this node" + ) class Config: from_attributes = True diff --git a/src/meshcore_hub/common/schemas/user_profiles.py b/src/meshcore_hub/common/schemas/user_profiles.py new file mode 100644 index 0000000..0db925b --- /dev/null +++ b/src/meshcore_hub/common/schemas/user_profiles.py @@ -0,0 +1,67 @@ +"""Pydantic schemas for user profile API endpoints.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class UserProfileRead(BaseModel): + """Schema for reading a user profile.""" + + id: str = Field(..., description="Profile UUID") + user_id: str = Field(..., description="OIDC subject identifier") + name: Optional[str] = Field(default=None, description="User's display name") + callsign: Optional[str] = Field(default=None, description="Amateur radio callsign") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + class Config: + from_attributes = True + + +class UserProfileUpdate(BaseModel): + """Schema for updating a user profile.""" + + name: Optional[str] = Field( + default=None, + min_length=1, + max_length=255, + description="User's display name", + ) + callsign: Optional[str] = Field( + default=None, + max_length=20, + description="Amateur radio callsign", + ) + + +class AdoptedNodeRead(BaseModel): + """Schema for reading an adopted node in the context of a user profile.""" + + public_key: str = Field(..., description="Node's 64-character hex public key") + name: Optional[str] = Field(default=None, description="Node display name") + adv_type: Optional[str] = Field(default=None, description="Advertisement type") + adopted_at: datetime = Field(..., description="When the node was adopted") + + class Config: + from_attributes = True + + +class UserProfileWithNodes(UserProfileRead): + """Schema for reading a user profile with adopted nodes.""" + + nodes: list[AdoptedNodeRead] = Field( + default_factory=list, + description="Nodes adopted by this user", + ) + + +class NodeAdoptRequest(BaseModel): + """Schema for adopting a node.""" + + public_key: str = Field( + ..., + max_length=64, + description="Public key of the node to adopt", + ) diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index eea1641..6c68972 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -24,8 +24,8 @@ from meshcore_hub.common.i18n import load_locale, t from meshcore_hub.common.schemas import RadioConfig from meshcore_hub.web.middleware import CacheControlMiddleware from meshcore_hub.web.oidc import ( + get_session_roles, get_session_user, - get_user_roles, init_oidc, oauth, strip_userinfo, @@ -41,6 +41,98 @@ TEMPLATES_DIR = PACKAGE_DIR / "templates" STATIC_DIR = PACKAGE_DIR / "static" +# Per-endpoint, per-method role access mapping for the API proxy. +# Key: URL path prefix (after /api/), Value: {method -> allowed roles}. +# _OPEN = unconditional access (OIDC on or off, anonymous OK). +# Method not listed = denied. No prefix match = denied. +_OPEN: frozenset[str] = frozenset() + + +def _build_endpoint_access( + role_admin: str, + role_operator: str = "operator", + role_member: str = "member", +) -> dict[str, dict[str, frozenset[str]]]: + """Build the per-endpoint access mapping using configured role names. + + Args: + role_admin: The IdP role name that grants admin access. + role_operator: The IdP role name that grants operator access. + role_member: The IdP role name that grants member access. + + Returns: + Endpoint access mapping dict. + """ + admin = frozenset({role_admin}) + any_authenticated = frozenset({role_admin, role_operator, role_member}) + operator_admin = frozenset({role_admin, role_operator}) + return { + "v1/nodes": { + "GET": _OPEN, + }, + "v1/nodes/": { + "GET": _OPEN, + "POST": admin, + "PUT": admin, + "DELETE": admin, + }, + "v1/members": { + "GET": _OPEN, + "POST": admin, + "PUT": admin, + "DELETE": admin, + }, + "v1/messages": { + "GET": _OPEN, + }, + "v1/advertisements": { + "GET": _OPEN, + }, + "v1/dashboard": { + "GET": _OPEN, + }, + "v1/trace-paths": { + "GET": _OPEN, + }, + "v1/telemetry": { + "GET": _OPEN, + }, + "v1/adoptions": { + "POST": operator_admin, + "DELETE": operator_admin, + }, + "v1/user/profile": { + "GET": any_authenticated, + "PUT": any_authenticated, + }, + } + + +def check_api_access( + path: str, + method: str, + oidc_enabled: bool, + user_roles: frozenset[str], + mapping: dict[str, dict[str, frozenset[str]]], +) -> bool: + """Check if user has required role for the given API path + method. + + Longest prefix wins. Method must be explicitly listed. + _OPEN means unconditional access. Specific roles require OIDC on + role match. + """ + for prefix in sorted(mapping, key=len, reverse=True): + if path.startswith(prefix): + required = mapping[prefix].get(method) + if required is None: + return False + if not required: + return True + if not oidc_enabled: + return False + return bool(user_roles & required) + return False + + def _parse_decoder_key_entries(raw: str | None) -> list[str]: """Parse COLLECTOR_CHANNEL_KEYS into key entries.""" if not raw: @@ -182,28 +274,30 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "auto_refresh_seconds": app.state.auto_refresh_seconds, "channel_labels": app.state.channel_labels, "logo_invert_light": app.state.logo_invert_light, + "debug": app.state.web_debug, + } + + role_names = { + "admin": app.state.oidc_role_admin, + "operator": app.state.oidc_role_operator, + "member": app.state.oidc_role_member, } if getattr(app.state, "oidc_enabled", False): user = get_session_user(request) - is_member, is_admin = get_user_roles( - request, - app.state.oidc_roles_claim, - app.state.oidc_admin_role, - app.state.oidc_member_role, - ) + roles = get_session_roles(request, app.state.oidc_roles_claim) config.update( oidc_enabled=True, user=user, - is_member=is_member, - is_admin=is_admin, + roles=roles, + role_names=role_names, ) else: config.update( oidc_enabled=False, user=None, - is_member=False, - is_admin=False, + roles=[], + role_names=role_names, ) # Escape "" sequences to prevent XSS breakout from the @@ -292,10 +386,20 @@ def create_app( app.state.oidc_redirect_uri = settings.oidc_redirect_uri app.state.oidc_post_logout_redirect_uri = settings.oidc_post_logout_redirect_uri app.state.oidc_roles_claim = settings.oidc_roles_claim - app.state.oidc_admin_role = settings.oidc_admin_role - app.state.oidc_member_role = settings.oidc_member_role + app.state.oidc_role_admin = settings.oidc_role_admin + app.state.oidc_role_operator = settings.oidc_role_operator + app.state.oidc_role_member = settings.oidc_role_member else: app.state.oidc_enabled = False + app.state.oidc_role_admin = settings.oidc_role_admin + app.state.oidc_role_operator = settings.oidc_role_operator + app.state.oidc_role_member = settings.oidc_role_member + + app.state.endpoint_access = _build_endpoint_access( + role_admin=settings.oidc_role_admin, + role_operator=settings.oidc_role_operator, + role_member=settings.oidc_role_member, + ) # Load i18n translations app.state.web_locale = settings.web_locale or "en" @@ -304,6 +408,7 @@ def create_app( # Auto-refresh interval app.state.auto_refresh_seconds = settings.web_auto_refresh_seconds + app.state.web_debug = settings.web_debug app.state.channel_labels = _build_channel_labels() # Store configuration in app state (use args if provided, else settings) @@ -455,24 +560,22 @@ def create_app( ) async def api_proxy(request: Request, path: str) -> Response: """Proxy API requests to the backend API server.""" - # OIDC write gating: block non-admin sessions - if request.app.state.oidc_enabled and request.method in ( - "POST", - "PUT", - "DELETE", - "PATCH", + oidc_enabled = getattr(request.app.state, "oidc_enabled", False) + user_roles: frozenset[str] = frozenset() + if oidc_enabled: + roles_claim = getattr(request.app.state, "oidc_roles_claim", "roles") + user_roles = frozenset(get_session_roles(request, roles_claim)) + if not check_api_access( + path, + request.method, + oidc_enabled, + user_roles, + request.app.state.endpoint_access, ): - _, is_admin = get_user_roles( - request, - request.app.state.oidc_roles_claim, - request.app.state.oidc_admin_role, - request.app.state.oidc_member_role, + return JSONResponse( + {"detail": "Access denied", "code": "AUTH_REQUIRED"}, + status_code=403, ) - if not is_admin: - return JSONResponse( - {"detail": "Admin access required", "code": "AUTH_REQUIRED"}, - status_code=403, - ) client: httpx.AsyncClient = request.app.state.http_client url = f"/api/{path}" @@ -490,6 +593,15 @@ def create_app( if "content-type" in request.headers: headers["content-type"] = request.headers["content-type"] + # Inject authenticated user identity when OIDC is enabled + if oidc_enabled: + user = get_session_user(request) + if user and user.get("sub"): + headers["X-User-Id"] = user["sub"] + roles = get_session_roles(request, roles_claim) + if roles: + headers["X-User-Roles"] = ",".join(roles) + try: response = await client.request( method=request.method, @@ -895,15 +1007,11 @@ def create_app( user = get_session_user(request) if not user: return JSONResponse({"detail": "Not authenticated"}, status_code=401) - is_member, is_admin = get_user_roles( + roles = get_session_roles( request, request.app.state.oidc_roles_claim, - request.app.state.oidc_admin_role, - request.app.state.oidc_member_role, - ) - return JSONResponse( - {"user": user, "is_member": is_member, "is_admin": is_admin} ) + return JSONResponse({"user": user, "roles": roles}) # --- SPA Catch-All (MUST be last) --- @app.api_route("/{path:path}", methods=["GET"], tags=["SPA"], response_model=None) diff --git a/src/meshcore_hub/web/oidc.py b/src/meshcore_hub/web/oidc.py index 44447b2..652c986 100644 --- a/src/meshcore_hub/web/oidc.py +++ b/src/meshcore_hub/web/oidc.py @@ -42,26 +42,17 @@ def get_session_user(request: Request) -> dict[str, Any] | None: return request.session.get("user") -def get_user_roles( - request: Request, roles_claim: str, admin_role: str, member_role: str -) -> tuple[bool, bool]: - """Extract roles from session. Returns (is_member, is_admin).""" +def get_session_roles(request: Request, roles_claim: str) -> list[str]: + """Extract roles from session. Returns list of role name strings.""" user = get_session_user(request) if not user: - return False, False + return [] roles: Any = user.get(roles_claim, []) if isinstance(roles, str): - roles = [roles] - is_admin = admin_role in roles - is_member = member_role in roles - logger.info( - "OIDC roles check: roles_claim=%s, raw_roles=%s, is_member=%s, is_admin=%s", - roles_claim, - roles, - is_member, - is_admin, - ) - return is_member, is_admin + return [roles] + if isinstance(roles, list): + return roles + return [] def strip_userinfo(userinfo: dict[str, Any], roles_claim: str) -> dict[str, Any]: diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index 335d17f..4aa0f28 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -6,7 +6,7 @@ */ import { Router } from './router.js'; -import { getConfig, renderAuthSection } from './components.js'; +import { getConfig, hasRole, renderAuthSection } from './components.js'; import { loadLocale, t } from './i18n.js'; // Page modules (lazy-loaded) @@ -24,6 +24,7 @@ const pages = { adminIndex: () => import('./pages/admin/index.js'), adminNodeTags: () => import('./pages/admin/node-tags.js'), adminMembers: () => import('./pages/admin/members.js'), + profile: () => import('./pages/profile.js'), }; // Main app container @@ -88,13 +89,18 @@ if (features.pages !== false) { } // Admin routes (only register when OIDC disabled or user is admin) -if (!config.oidc_enabled || config.is_admin) { +if (hasRole('admin')) { router.addRoute('/admin', pageHandler(pages.adminIndex)); router.addRoute('/admin/', pageHandler(pages.adminIndex)); router.addRoute('/admin/node-tags', pageHandler(pages.adminNodeTags)); router.addRoute('/admin/members', pageHandler(pages.adminMembers)); } +// Profile route (only register when OIDC enabled) +if (config.oidc_enabled) { + router.addRoute('/profile', pageHandler(pages.profile)); +} + // 404 handler router.setNotFound(pageHandler(pages.notFound)); @@ -160,6 +166,7 @@ function updatePageTitle(pathname) { if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements'); if (features.map !== false) titles['/map'] = composePageTitle('entities.map'); if (features.members !== false) titles['/members'] = composePageTitle('entities.members'); + titles['/profile'] = composePageTitle('links.profile'); if (titles[pathname]) { document.title = titles[pathname]; diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 12ee2d3..e7f593f 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -23,6 +23,21 @@ export function getConfig() { return window.__APP_CONFIG__ || {}; } +/** + * Check if the current session has a specific role. + * Returns true when OIDC is disabled (open access). + * Translates symbolic role names (e.g. "admin") to actual IdP role names + * via the role_names config mapping. + * @param {string} roleName - Symbolic role to check + * @returns {boolean} + */ +export function hasRole(roleName) { + const config = getConfig(); + if (!config.oidc_enabled) return true; + const actualRole = (config.role_names || {})[roleName] || roleName; + return (config.roles || []).includes(actualRole); +} + /** * Build channel label map from app config. * Keys are numeric channel indexes and values are non-empty labels. @@ -577,6 +592,18 @@ export function submitOnEnter(e) { * @param {HTMLElement} container - The #auth-section element * @param {Object} config - App configuration object */ +function _svgUser() { + return ''; +} + +function _svgSettings() { + return ''; +} + +function _svgLogout() { + return ''; +} + export function renderAuthSection(container, config) { if (!container) return; if (!config.oidc_enabled) { @@ -597,11 +624,25 @@ export function renderAuthSection(container, config) { const pictureHtml = user.picture ? `${displayName}` : `${initials}`; - const roleBadge = config.is_admin - ? `${t('auth.role_admin')}` - : config.is_member - ? `${t('auth.role_member')}` - : ''; + + const roleBadges = (config.roles || []) + .map(r => { + const key = `auth.role_${r}`; + const label = t(key); + const name = label !== key ? label : r; + return `${name}`; + }) + .join(''); + + const adminItem = hasRole('admin') + ? `
  • ${_svgSettings()} ${t('entities.admin')}
  • ` + : ''; + + const profileItem = `
  • ${_svgUser()} ${t('links.profile')}
  • `; + + const debugId = config.debug && user.sub + ? `${user.sub}` + : ''; container.innerHTML = ` `; diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js index 5150cc4..d39702b 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js @@ -1,11 +1,11 @@ -import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../../components.js'; +import { html, litRender, unsafeHTML, getConfig, hasRole, errorAlert, t } from '../../components.js'; import { iconLock, iconUsers, iconTag } from '../../icons.js'; export async function render(container, params, router) { try { const config = getConfig(); - if (config.oidc_enabled ? !config.is_admin : false) { + if (!hasRole('admin')) { litRender(html`
    ${iconLock('h-16 w-16 opacity-30 mb-4')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js index f5aac76..54ebf09 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js @@ -1,7 +1,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js'; import { html, litRender, nothing, - getConfig, errorAlert, successAlert, t, escapeHtml, + getConfig, hasRole, errorAlert, successAlert, t, escapeHtml, } from '../../components.js'; import { iconLock } from '../../icons.js'; @@ -9,7 +9,7 @@ export async function render(container, params, router) { try { const config = getConfig(); - if (!config.is_admin && config.oidc_enabled) { + if (!hasRole('admin')) { litRender(html`
    ${iconLock('h-16 w-16 opacity-30 mb-4')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js index 4fb9da1..0f27445 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js @@ -1,7 +1,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js'; import { html, litRender, nothing, unsafeHTML, - getConfig, typeEmoji, formatDateTimeShort, errorAlert, + getConfig, hasRole, typeEmoji, formatDateTimeShort, errorAlert, successAlert, truncateKey, t, escapeHtml, } from '../../components.js'; import { iconTag, iconLock } from '../../icons.js'; @@ -10,7 +10,7 @@ export async function render(container, params, router) { try { const config = getConfig(); - if (!config.is_admin && config.oidc_enabled) { + if (!hasRole('admin')) { litRender(html`
    ${iconLock('h-16 w-16 opacity-30 mb-4')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index abac38f..d162c61 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -1,8 +1,8 @@ -import { apiGet } from '../api.js'; +import { apiGet, apiPost, apiDelete } from '../api.js'; import { html, litRender, nothing, - getConfig, typeEmoji, formatDateTime, - truncateKey, errorAlert, copyToClipboard, t, + getConfig, hasRole, typeEmoji, formatDateTime, + truncateKey, errorAlert, successAlert, copyToClipboard, t, } from '../components.js'; import { iconError } from '../icons.js'; @@ -126,12 +126,18 @@ export async function render(container, params, router) {
    ` : html`

    ${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}

    `; - const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false) + const adminTagsHtml = hasRole('admin') ? html`` : nothing; + const adoptionHtml = renderAdoptionSection(node, config); + + const flashMessage = (params.query && params.query.message) || ''; + const flashError = (params.query && params.query.error) || ''; + const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing; + litRender(html` + +${adoptionHtml}`, container); // Initialize map if coordinates exist if (hasCoords && typeof L !== 'undefined') { @@ -227,6 +237,32 @@ ${heroHtml} cleanupFns.push(() => clearInterval(qrInterval)); } + // Wire up adoption buttons + const adoptBtn = container.querySelector('.btn-adopt-node'); + if (adoptBtn) { + adoptBtn.addEventListener('click', async () => { + try { + await apiPost('/api/v1/adoptions', { public_key: node.public_key }); + router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.adopt_success')), true); + } catch (err) { + router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true); + } + }); + } + + const releaseBtn = container.querySelector('.btn-release-node'); + if (releaseBtn) { + releaseBtn.addEventListener('click', async () => { + if (!confirm(t('nodes.release_confirm'))) return; + try { + await apiDelete('/api/v1/adoptions/' + node.public_key); + router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.release_success')), true); + } catch (err) { + router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true); + } + }); + } + return () => { cleanupFns.forEach(fn => fn()); }; @@ -239,6 +275,55 @@ ${heroHtml} } } +function renderAdoptionSection(node, config) { + if (!config.oidc_enabled || !config.user) return nothing; + + const isOperator = hasRole('operator'); + const isAdmin = hasRole('admin'); + if (!isOperator && !isAdmin) { + if (node.adopted_by) { + const ownerName = node.adopted_by.name || node.adopted_by.user_id; + return html`
    +
    +

    ${t('nodes.ownership')}

    +

    ${t('nodes.adopted_by', { name: ownerName })}

    +
    +
    `; + } + return nothing; + } + + if (node.adopted_by) { + const ownerName = node.adopted_by.name || node.adopted_by.user_id; + const isOwner = node.adopted_by.user_id === config.user.sub; + const canRelease = isOwner || isAdmin; + + const releaseBtnHtml = canRelease + ? html`` + : nothing; + + return html`
    +
    +

    ${t('nodes.ownership')}

    +
    +

    ${t('nodes.adopted_by', { name: ownerName })}

    + ${releaseBtnHtml} +
    +
    +
    `; + } + + return html`
    +
    +

    ${t('nodes.ownership')}

    +

    ${t('nodes.not_adopted')}

    +
    + +
    +
    +
    `; +} + function renderNotFound(publicKey) { return html`