mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 10:52:48 +02:00
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.
This commit is contained in:
@@ -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 |
|
||||
|
||||
+13
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 ###
|
||||
+4
-2
@@ -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}
|
||||
|
||||
+52
-9
@@ -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.
|
||||
|
||||
+27
-1
@@ -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 `<code>`, `<strong>`, or `<br/>` tags - keep these intact.
|
||||
|
||||
+88
-4
@@ -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_<NAME>`) |
|
||||
| `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.
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"<UserProfile(id={self.id}, user_id={self.user_id}, name={self.name})>"
|
||||
@@ -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"<UserProfileNode("
|
||||
f"user_profile_id={self.user_profile_id}, "
|
||||
f"node_id={self.node_id})>"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
+143
-35
@@ -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 "</script>" 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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>';
|
||||
}
|
||||
|
||||
function _svgSettings() {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>';
|
||||
}
|
||||
|
||||
function _svgLogout() {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>';
|
||||
}
|
||||
|
||||
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
|
||||
? `<img src="${user.picture}" alt="${displayName}" class="w-8 h-8 rounded-full" />`
|
||||
: `<span class="text-sm font-bold">${initials}</span>`;
|
||||
const roleBadge = config.is_admin
|
||||
? `<span class="badge badge-primary badge-sm">${t('auth.role_admin')}</span>`
|
||||
: config.is_member
|
||||
? `<span class="badge badge-ghost badge-sm">${t('auth.role_member')}</span>`
|
||||
: '';
|
||||
|
||||
const roleBadges = (config.roles || [])
|
||||
.map(r => {
|
||||
const key = `auth.role_${r}`;
|
||||
const label = t(key);
|
||||
const name = label !== key ? label : r;
|
||||
return `<span class="badge badge-primary badge-xs">${name}</span>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const adminItem = hasRole('admin')
|
||||
? `<li><a href="/admin/">${_svgSettings()} ${t('entities.admin')}</a></li>`
|
||||
: '';
|
||||
|
||||
const profileItem = `<li><a href="/profile">${_svgUser()} ${t('links.profile')}</a></li>`;
|
||||
|
||||
const debugId = config.debug && user.sub
|
||||
? `<span class="text-xs opacity-40 font-mono">${user.sub}</span>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="dropdown dropdown-end">
|
||||
@@ -609,8 +650,17 @@ export function renderAuthSection(container, config) {
|
||||
${pictureHtml}
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm z-[1] p-2 shadow bg-base-100 rounded-box w-52 mt-3">
|
||||
<li class="menu-title"><span>${displayName}</span>${roleBadge}</li>
|
||||
<li><a href="/auth/logout">${t('auth.logout')}</a></li>
|
||||
<li class="menu-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium">${displayName}</span>
|
||||
${debugId}
|
||||
${roleBadges ? `<div class="flex flex-wrap gap-1">${roleBadges}</div>` : ''}
|
||||
</div>
|
||||
</li>
|
||||
<hr class="my-1 opacity-20">
|
||||
${adminItem}
|
||||
${profileItem}
|
||||
<li><a href="/auth/logout">${_svgLogout()} ${t('auth.logout')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
|
||||
@@ -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`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
|
||||
@@ -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`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
${iconLock('h-16 w-16 opacity-30 mb-4')}
|
||||
|
||||
@@ -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) {
|
||||
</div>`
|
||||
: html`<p class="opacity-70">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</p>`;
|
||||
|
||||
const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false)
|
||||
const adminTagsHtml = hasRole('admin')
|
||||
? html`<div class="mt-3">
|
||||
<a href="/admin/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
|
||||
</div>`
|
||||
: 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`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
@@ -151,6 +157,8 @@ export async function render(container, params, router) {
|
||||
|
||||
${heroHtml}
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
@@ -182,7 +190,9 @@ ${heroHtml}
|
||||
${adminTagsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>`, container);
|
||||
</div>
|
||||
|
||||
${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`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('nodes.ownership')}</h2>
|
||||
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
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`<button class="btn btn-sm btn-outline btn-error btn-release-node">${t('nodes.release')}</button>`
|
||||
: nothing;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('nodes.ownership')}</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
|
||||
${releaseBtnHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('nodes.ownership')}</h2>
|
||||
<p class="text-sm opacity-70">${t('nodes.not_adopted')}</p>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-primary btn-adopt-node">${t('nodes.adopt')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNotFound(publicKey) {
|
||||
return html`
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { apiGet, apiPut } from '../api.js';
|
||||
import {
|
||||
html, litRender, nothing,
|
||||
getConfig, t, errorAlert, successAlert,
|
||||
formatRelativeTime, formatDateTime,
|
||||
} from '../components.js';
|
||||
|
||||
function renderAdoptedNode(node) {
|
||||
const displayName = node.name || node.public_key.slice(0, 12) + '...';
|
||||
const relTime = formatRelativeTime(node.adopted_at);
|
||||
const fullTime = formatDateTime(node.adopted_at);
|
||||
|
||||
return html`<a href="/nodes/${node.public_key}" class="flex items-center justify-between gap-3 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">${displayName}</div>
|
||||
<div class="font-mono text-xs opacity-60 truncate">${node.public_key}</div>
|
||||
</div>
|
||||
<time class="text-xs opacity-60 whitespace-nowrap shrink-0" datetime=${node.adopted_at} title=${fullTime} data-relative-time>${relTime}</time>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.oidc_enabled || !config.user) {
|
||||
litRender(html`
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<h1 class="text-3xl font-bold mb-2">${t('user_profile.title')}</h1>
|
||||
<p class="opacity-70 mb-6">${t('user_profile.login_to_view')}</p>
|
||||
<a href="/auth/login" class="btn btn-primary">${t('auth.login')}</a>
|
||||
</div>`, container);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = config.user.sub;
|
||||
const profilePath = `/api/v1/user/profile/${encodeURIComponent(userId)}`;
|
||||
const profile = await apiGet(profilePath);
|
||||
|
||||
const flashMessage = (params.query && params.query.message) || '';
|
||||
const flashError = (params.query && params.query.error) || '';
|
||||
const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
|
||||
|
||||
const nodesHtml = profile.nodes && profile.nodes.length > 0
|
||||
? html`<div class="space-y-2">${profile.nodes.map(n => renderAdoptedNode(n))}</div>`
|
||||
: html`<p class="text-base-content/60 text-sm py-4">${t('user_profile.no_adopted_nodes')}</p>`;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('user_profile.title')}</h1>
|
||||
</div>
|
||||
|
||||
${flashHtml}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('user_profile.your_profile')}</h2>
|
||||
<form id="profile-form" class="py-4 space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('user_profile.name_label')}</span></label>
|
||||
<input type="text" name="name" class="input input-bordered"
|
||||
value=${profile.name || ''}
|
||||
placeholder=${t('user_profile.name_placeholder')} maxlength="255" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">${t('user_profile.callsign_label')}</span></label>
|
||||
<input type="text" name="callsign" class="input input-bordered"
|
||||
value=${profile.callsign || ''}
|
||||
placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">${t('user_profile.save_profile')}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">${t('user_profile.adopted_nodes')}</h2>
|
||||
${nodesHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>`, container);
|
||||
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
|
||||
container.querySelector('#profile-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = {
|
||||
name: form.name.value.trim() || null,
|
||||
callsign: form.callsign.value.trim() || null,
|
||||
};
|
||||
try {
|
||||
await apiPut(profilePath, body);
|
||||
router.navigate('/profile?message=' + encodeURIComponent(t('user_profile.profile_updated')), true);
|
||||
} catch (err) {
|
||||
router.navigate('/profile?error=' + encodeURIComponent(err.message), true);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
return () => ac.abort();
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,15 @@
|
||||
"channel": "Channel {{number}}"
|
||||
},
|
||||
"nodes": {
|
||||
"scan_to_add": "Scan to add as contact"
|
||||
"scan_to_add": "Scan to add as contact",
|
||||
"ownership": "Ownership",
|
||||
"adopt": "Adopt",
|
||||
"release": "Release",
|
||||
"adopted_by": "Adopted by {{name}}",
|
||||
"not_adopted": "This node has not been adopted by any operator.",
|
||||
"adopt_success": "Node adopted successfully",
|
||||
"release_success": "Node released successfully",
|
||||
"release_confirm": "Are you sure you want to release this node?"
|
||||
},
|
||||
"advertisements": {},
|
||||
"messages": {
|
||||
@@ -201,6 +209,19 @@
|
||||
"role_admin": "admin",
|
||||
"role_member": "member"
|
||||
},
|
||||
"user_profile": {
|
||||
"title": "Profile",
|
||||
"your_profile": "Your Profile",
|
||||
"profile_updated": "Profile updated successfully",
|
||||
"save_profile": "Save Profile",
|
||||
"name_label": "Display Name",
|
||||
"name_placeholder": "Your name or preferred name",
|
||||
"callsign_label": "Callsign",
|
||||
"callsign_placeholder": "Amateur radio callsign (e.g., W1ABC)",
|
||||
"adopted_nodes": "Adopted Nodes",
|
||||
"no_adopted_nodes": "No adopted nodes",
|
||||
"login_to_view": "Log in to view your profile"
|
||||
},
|
||||
"admin_members": {
|
||||
"network_members": "Network Members ({{count}})",
|
||||
"member_id": "Member ID",
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/admin/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-xs opacity-50 mt-2">{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from meshcore_hub.common.models import (
|
||||
NodeTag,
|
||||
Telemetry,
|
||||
TracePath,
|
||||
UserProfile,
|
||||
UserProfileNode,
|
||||
)
|
||||
|
||||
|
||||
@@ -424,3 +426,30 @@ def sample_node_with_member_tag(api_db_session):
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_profile(api_db_session):
|
||||
"""Create a sample user profile in the database."""
|
||||
profile = UserProfile(
|
||||
user_id="oidc-user-123",
|
||||
name="Test User",
|
||||
callsign="W1TEST",
|
||||
)
|
||||
api_db_session.add(profile)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_adopted_node(api_db_session, sample_user_profile, sample_node):
|
||||
"""Create a sample adopted node association."""
|
||||
association = UserProfileNode(
|
||||
user_profile_id=sample_user_profile.id,
|
||||
node_id=sample_node.id,
|
||||
)
|
||||
api_db_session.add(association)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(association)
|
||||
return association
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for node adoption API routes."""
|
||||
|
||||
from meshcore_hub.common.models import UserProfile, UserProfileNode
|
||||
|
||||
TEST_USER_ID = "oidc-user-123"
|
||||
OTHER_USER_ID = "oidc-user-456"
|
||||
OPERATOR_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "operator",
|
||||
}
|
||||
ADMIN_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "admin",
|
||||
}
|
||||
OTHER_OPERATOR_HEADERS = {
|
||||
"X-User-Id": OTHER_USER_ID,
|
||||
"X-User-Roles": "operator",
|
||||
}
|
||||
OTHER_ADMIN_HEADERS = {
|
||||
"X-User-Id": OTHER_USER_ID,
|
||||
"X-User-Roles": "admin",
|
||||
}
|
||||
MEMBER_ONLY_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "member",
|
||||
}
|
||||
NO_ROLES_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "",
|
||||
}
|
||||
|
||||
|
||||
class TestAdoptNode:
|
||||
"""Tests for POST /v1/adoptions endpoint."""
|
||||
|
||||
def test_adopt_node_success(self, client_no_auth, sample_node):
|
||||
"""Test adopting a node."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["public_key"] == sample_node.public_key
|
||||
assert data["name"] == sample_node.name
|
||||
assert "adopted_at" in data
|
||||
|
||||
def test_adopt_node_auto_creates_profile(self, client_no_auth, sample_node):
|
||||
"""Test adopting a node auto-creates the user profile."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_adopt_node_by_admin(self, client_no_auth, sample_node):
|
||||
"""Test an admin can adopt a node."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_adopt_node_duplicate(self, client_no_auth, sample_adopted_node):
|
||||
"""Test adopting an already-adopted node by the same user fails."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": "abc123def456abc123def456abc123de"},
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already adopted" in response.json()["detail"].lower()
|
||||
|
||||
def test_adopt_node_already_adopted_by_other(
|
||||
self, client_no_auth, api_db_session, sample_node
|
||||
):
|
||||
"""Test adopting a node adopted by another user fails."""
|
||||
other_profile = UserProfile(user_id=OTHER_USER_ID, name="Other")
|
||||
api_db_session.add(other_profile)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(other_profile)
|
||||
|
||||
assoc = UserProfileNode(
|
||||
user_profile_id=other_profile.id,
|
||||
node_id=sample_node.id,
|
||||
)
|
||||
api_db_session.add(assoc)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "another user" in response.json()["detail"].lower()
|
||||
|
||||
def test_adopt_node_not_found(self, client_no_auth):
|
||||
"""Test adopting a non-existent node fails."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": "a" * 64},
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_adopt_node_requires_operator_or_admin_role(
|
||||
self, client_no_auth, sample_node
|
||||
):
|
||||
"""Test adopting requires operator or admin role."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
headers=MEMBER_ONLY_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_adopt_node_requires_role_header(self, client_no_auth, sample_node):
|
||||
"""Test adopting requires role in X-User-Roles."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
headers=NO_ROLES_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_adopt_node_rejects_missing_user_id(self, client_no_auth, sample_node):
|
||||
"""Test adopting without X-User-Id header is rejected."""
|
||||
response = client_no_auth.post(
|
||||
"/api/v1/adoptions",
|
||||
json={"public_key": sample_node.public_key},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestReleaseNode:
|
||||
"""Tests for DELETE /v1/adoptions/{public_key} endpoint."""
|
||||
|
||||
def test_release_own_node_success(
|
||||
self, client_no_auth, sample_node, sample_adopted_node
|
||||
):
|
||||
"""Test operator releasing their own adopted node."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_release_node_not_adopted(self, client_no_auth, sample_node):
|
||||
"""Test releasing a node that is not adopted."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_release_node_not_found(self, client_no_auth):
|
||||
"""Test releasing a non-existent node fails."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{'z' * 64}",
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_release_node_requires_operator_or_admin(
|
||||
self, client_no_auth, sample_node, sample_adopted_node
|
||||
):
|
||||
"""Test releasing requires operator or admin role."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
headers=MEMBER_ONLY_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_operator_cannot_release_others_node(
|
||||
self, client_no_auth, api_db_session, sample_node, sample_user_profile
|
||||
):
|
||||
"""Test operator cannot release a node adopted by another user."""
|
||||
other_profile = UserProfile(user_id=OTHER_USER_ID, name="Other")
|
||||
api_db_session.add(other_profile)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(other_profile)
|
||||
|
||||
assoc = UserProfileNode(
|
||||
user_profile_id=other_profile.id,
|
||||
node_id=sample_node.id,
|
||||
)
|
||||
api_db_session.add(assoc)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
headers=OPERATOR_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_admin_can_release_others_node(
|
||||
self, client_no_auth, api_db_session, sample_node
|
||||
):
|
||||
"""Test admin can release a node adopted by another user."""
|
||||
other_profile = UserProfile(user_id=OTHER_USER_ID, name="Other")
|
||||
api_db_session.add(other_profile)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(other_profile)
|
||||
|
||||
assoc = UserProfileNode(
|
||||
user_profile_id=other_profile.id,
|
||||
node_id=sample_node.id,
|
||||
)
|
||||
api_db_session.add(assoc)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_admin_can_release_own_node(
|
||||
self, client_no_auth, sample_node, api_db_session
|
||||
):
|
||||
"""Test admin can release their own adopted node."""
|
||||
profile = UserProfile(user_id=TEST_USER_ID, name="Admin User")
|
||||
api_db_session.add(profile)
|
||||
api_db_session.commit()
|
||||
api_db_session.refresh(profile)
|
||||
|
||||
assoc = UserProfileNode(
|
||||
user_profile_id=profile.id,
|
||||
node_id=sample_node.id,
|
||||
)
|
||||
api_db_session.add(assoc)
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_release_node_rejects_missing_user_id(
|
||||
self, client_no_auth, sample_node, sample_adopted_node
|
||||
):
|
||||
"""Test releasing without X-User-Id header is rejected."""
|
||||
response = client_no_auth.delete(
|
||||
f"/api/v1/adoptions/{sample_node.public_key}",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -22,6 +22,21 @@ class TestListNodes:
|
||||
assert data["items"][0]["public_key"] == sample_node.public_key
|
||||
assert data["items"][0]["name"] == sample_node.name
|
||||
assert "tags" in data["items"][0]
|
||||
assert data["items"][0]["adopted_by"] is None
|
||||
|
||||
def test_list_nodes_with_adopted_node(
|
||||
self, client_no_auth, sample_node, sample_user_profile, sample_adopted_node
|
||||
):
|
||||
"""Test listing nodes includes adopted_by info."""
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
adopted_by = data["items"][0]["adopted_by"]
|
||||
assert adopted_by is not None
|
||||
assert adopted_by["user_id"] == "oidc-user-123"
|
||||
assert adopted_by["name"] == "Test User"
|
||||
assert adopted_by["callsign"] == "W1TEST"
|
||||
|
||||
def test_list_nodes_includes_tags(
|
||||
self, client_no_auth, sample_node, sample_node_tag
|
||||
@@ -180,6 +195,19 @@ class TestGetNode:
|
||||
assert data["name"] == sample_node.name
|
||||
assert "tags" in data
|
||||
assert data["tags"] == []
|
||||
assert data["adopted_by"] is None
|
||||
|
||||
def test_get_node_with_adoption(
|
||||
self, client_no_auth, sample_node, sample_user_profile, sample_adopted_node
|
||||
):
|
||||
"""Test getting a node shows adopted_by info."""
|
||||
response = client_no_auth.get(f"/api/v1/nodes/{sample_node.public_key}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["adopted_by"] is not None
|
||||
assert data["adopted_by"]["user_id"] == "oidc-user-123"
|
||||
assert data["adopted_by"]["name"] == "Test User"
|
||||
assert data["adopted_by"]["callsign"] == "W1TEST"
|
||||
|
||||
def test_get_node_with_tags(self, client_no_auth, sample_node, sample_node_tag):
|
||||
"""Test getting a node includes its tags."""
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for user profile API routes."""
|
||||
|
||||
TEST_USER_ID = "oidc-user-123"
|
||||
OTHER_USER_ID = "oidc-user-456"
|
||||
USER_HEADERS = {"X-User-Id": TEST_USER_ID, "X-User-Roles": "operator"}
|
||||
OTHER_USER_HEADERS = {"X-User-Id": OTHER_USER_ID, "X-User-Roles": "operator"}
|
||||
OPERATOR_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "operator",
|
||||
}
|
||||
MEMBER_ONLY_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "member",
|
||||
}
|
||||
NO_ROLES_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "",
|
||||
}
|
||||
|
||||
|
||||
class TestGetProfile:
|
||||
"""Tests for GET /user/profile/{user_id} endpoint."""
|
||||
|
||||
def test_get_profile_auto_creates(self, client_no_auth):
|
||||
"""Test getting a non-existent profile auto-creates it."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["name"] is None
|
||||
assert data["callsign"] is None
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert data["nodes"] == []
|
||||
|
||||
def test_get_existing_profile(self, client_no_auth, sample_user_profile):
|
||||
"""Test getting an existing profile."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == sample_user_profile.user_id
|
||||
assert data["name"] == sample_user_profile.name
|
||||
assert data["callsign"] == sample_user_profile.callsign
|
||||
|
||||
def test_get_profile_with_adopted_nodes(
|
||||
self, client_no_auth, sample_user_profile, sample_adopted_node
|
||||
):
|
||||
"""Test profile includes adopted nodes."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["nodes"]) == 1
|
||||
assert data["nodes"][0]["public_key"] == "abc123def456abc123def456abc123de"
|
||||
assert "adopted_at" in data["nodes"][0]
|
||||
|
||||
def test_get_profile_rejects_wrong_user(self, client_no_auth):
|
||||
"""Test that a user cannot access another user's profile."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers=OTHER_USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "access denied" in response.json()["detail"].lower()
|
||||
|
||||
def test_get_profile_rejects_missing_user_id(self, client_no_auth):
|
||||
"""Test that missing X-User-Id header is rejected."""
|
||||
response = client_no_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_profile_requires_auth(self, client_with_auth):
|
||||
"""Test getting profile requires auth when keys configured."""
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
response = client_with_auth.get(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
headers={
|
||||
**USER_HEADERS,
|
||||
"Authorization": "Bearer test-read-key",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestUpdateProfile:
|
||||
"""Tests for PUT /user/profile/{user_id} endpoint."""
|
||||
|
||||
def test_update_profile_name(self, client_no_auth, sample_user_profile):
|
||||
"""Test updating profile name."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
json={"name": "New Name"},
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "New Name"
|
||||
assert data["callsign"] == sample_user_profile.callsign
|
||||
|
||||
def test_update_profile_callsign(self, client_no_auth, sample_user_profile):
|
||||
"""Test updating profile callsign."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{sample_user_profile.user_id}",
|
||||
json={"callsign": "G1NEW"},
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["callsign"] == "G1NEW"
|
||||
assert data["name"] == sample_user_profile.name
|
||||
|
||||
def test_update_profile_auto_creates(self, client_no_auth):
|
||||
"""Test updating a non-existent profile auto-creates it."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
json={"name": "Auto Created", "callsign": "W1AUTO"},
|
||||
headers=USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["name"] == "Auto Created"
|
||||
assert data["callsign"] == "W1AUTO"
|
||||
|
||||
def test_update_profile_rejects_wrong_user(self, client_no_auth):
|
||||
"""Test that a user cannot update another user's profile."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
json={"name": "Hacked"},
|
||||
headers=OTHER_USER_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_update_profile_rejects_missing_user_id(self, client_no_auth):
|
||||
"""Test that missing X-User-Id header is rejected."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{TEST_USER_ID}",
|
||||
json={"name": "No Auth"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -89,7 +89,7 @@ class TestAdminHome:
|
||||
|
||||
In the SPA architecture, admin routes serve the same shell HTML.
|
||||
Admin access control is handled client-side based on
|
||||
window.__APP_CONFIG__.is_admin when OIDC is enabled.
|
||||
window.__APP_CONFIG__.roles when OIDC is enabled.
|
||||
"""
|
||||
|
||||
def test_admin_home_returns_spa_shell(self, admin_client):
|
||||
@@ -98,11 +98,11 @@ class TestAdminHome:
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_admin_home_config_is_admin(self, admin_client):
|
||||
"""Test admin config shows is_admin: true."""
|
||||
def test_admin_home_config_has_admin_role(self, admin_client):
|
||||
"""Test admin config includes admin in roles."""
|
||||
response = admin_client.get("/admin/")
|
||||
config = _extract_config(response.text)
|
||||
assert config["is_admin"] is True
|
||||
assert "admin" in config["roles"]
|
||||
assert config["oidc_enabled"] is True
|
||||
|
||||
def test_admin_home_disabled_returns_spa_shell(
|
||||
@@ -112,7 +112,7 @@ class TestAdminHome:
|
||||
"""Test admin page returns SPA shell even when OIDC disabled.
|
||||
|
||||
The SPA catch-all serves the shell for all routes.
|
||||
Client-side code checks oidc_enabled/is_admin to show/hide admin UI.
|
||||
Client-side code checks oidc_enabled/roles to show/hide admin UI.
|
||||
"""
|
||||
response = admin_client_disabled.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
@@ -146,23 +146,6 @@ class TestAdminNodeTags:
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
|
||||
class TestAdminFooterLink:
|
||||
"""Tests for admin link in footer."""
|
||||
|
||||
def test_admin_link_visible_when_oidc_enabled(self, admin_client):
|
||||
"""Test that admin link appears in footer when OIDC is enabled."""
|
||||
response = admin_client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/admin/"' in response.text
|
||||
assert "Admin" in response.text
|
||||
|
||||
def test_admin_link_hidden_when_oidc_disabled(self, admin_client_disabled):
|
||||
"""Test that admin link does not appear in footer when OIDC disabled."""
|
||||
response = admin_client_disabled.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/admin/"' not in response.text
|
||||
|
||||
|
||||
def _extract_config(text: str) -> dict[str, Any]:
|
||||
"""Extract __APP_CONFIG__ from SPA HTML."""
|
||||
start = text.find("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
|
||||
|
||||
@@ -90,7 +90,7 @@ class TestUnhandledExceptions:
|
||||
|
||||
def test_404_api_returns_json(self, error_client: TestClient) -> None:
|
||||
"""Test 404 on /api/ path returns JSON."""
|
||||
response = error_client.get("/api/v1/nonexistent")
|
||||
response = error_client.get("/api/v1/nodes/does_not_exist_pk")
|
||||
assert response.status_code == 404
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
data = response.json()
|
||||
|
||||
+27
-35
@@ -20,8 +20,7 @@ class TestOIDCSettingsValidation:
|
||||
config = _extract_config(text)
|
||||
assert config["oidc_enabled"] is False
|
||||
assert config["user"] is None
|
||||
assert config["is_admin"] is False
|
||||
assert config["is_member"] is False
|
||||
assert config["roles"] == []
|
||||
|
||||
def test_oidc_enabled_config_injection(self, client_with_oidc: TestClient) -> None:
|
||||
"""Test OIDC config injection when enabled (no session)."""
|
||||
@@ -30,8 +29,7 @@ class TestOIDCSettingsValidation:
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
assert config["user"] is None
|
||||
assert config["is_admin"] is False
|
||||
assert config["is_member"] is False
|
||||
assert config["roles"] == []
|
||||
|
||||
|
||||
class TestAuthLogin:
|
||||
@@ -141,8 +139,8 @@ class TestAuthUser:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["name"] == "Admin User"
|
||||
assert data["is_admin"] is True
|
||||
assert data["is_member"] is True
|
||||
assert "admin" in data["roles"]
|
||||
assert "member" in data["roles"]
|
||||
|
||||
def test_user_member_session(
|
||||
self, client_with_oidc_member_session: TestClient
|
||||
@@ -152,8 +150,8 @@ class TestAuthUser:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["name"] == "Member User"
|
||||
assert data["is_admin"] is False
|
||||
assert data["is_member"] is True
|
||||
assert "admin" not in data["roles"]
|
||||
assert "member" in data["roles"]
|
||||
|
||||
|
||||
class TestAdminRouteProtection:
|
||||
@@ -174,7 +172,7 @@ class TestAdminRouteProtection:
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
assert config["is_admin"] is False
|
||||
assert "admin" not in config["roles"]
|
||||
|
||||
def test_admin_session_gets_spa_shell(
|
||||
self, client_with_oidc_admin_session: TestClient
|
||||
@@ -184,23 +182,23 @@ class TestAdminRouteProtection:
|
||||
assert response.status_code == 200
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
assert config["is_admin"] is True
|
||||
assert "admin" in config["roles"]
|
||||
|
||||
|
||||
class TestAPIProxyWriteGating:
|
||||
"""Test API proxy write method gating when OIDC enabled."""
|
||||
|
||||
def test_get_not_gated(self, client_with_oidc: TestClient) -> None:
|
||||
"""Test GET requests are not gated."""
|
||||
"""Test GET requests to open endpoints are not gated."""
|
||||
response = client_with_oidc.get("/api/v1/nodes")
|
||||
assert response.status_code != 403
|
||||
|
||||
def test_post_blocked_for_non_admin(
|
||||
def test_post_blocked_for_member(
|
||||
self, client_with_oidc_member_session: TestClient
|
||||
) -> None:
|
||||
"""Test POST blocked for member session."""
|
||||
response = client_with_oidc_member_session.post(
|
||||
"/api/v1/node-tags", json={"key": "test", "value": "test"}
|
||||
"/api/v1/members", json={"name": "test"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -209,18 +207,19 @@ class TestAPIProxyWriteGating:
|
||||
) -> None:
|
||||
"""Test POST allowed for admin session."""
|
||||
response = client_with_oidc_admin_session.post(
|
||||
"/api/v1/node-tags",
|
||||
json={"key": "test", "value": "test"},
|
||||
"/api/v1/members",
|
||||
json={"name": "test"},
|
||||
)
|
||||
# Will get 404 from mock since we didn't set up the endpoint,
|
||||
# but should not get 403
|
||||
assert response.status_code != 403
|
||||
|
||||
def test_write_not_gated_when_oidc_disabled(self, client: TestClient) -> None:
|
||||
"""Test write methods not gated when OIDC is disabled."""
|
||||
response = client.post(
|
||||
"/api/v1/node-tags", json={"key": "test", "value": "test"}
|
||||
)
|
||||
def test_write_blocked_when_oidc_disabled(self, client: TestClient) -> None:
|
||||
"""Test write methods blocked when OIDC is disabled."""
|
||||
response = client.post("/api/v1/members", json={"name": "test"})
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_read_open_when_oidc_disabled(self, client: TestClient) -> None:
|
||||
"""Test GET to open endpoints allowed when OIDC is disabled."""
|
||||
response = client.get("/api/v1/nodes")
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
@@ -233,7 +232,7 @@ class TestBackwardCompatibility:
|
||||
config = _extract_config(response.text)
|
||||
assert "admin_enabled" not in config
|
||||
assert config["oidc_enabled"] is False
|
||||
assert config["is_admin"] is False
|
||||
assert config["roles"] == []
|
||||
|
||||
def test_admin_routes_serve_spa_shell_when_oidc_disabled(
|
||||
self, client: TestClient
|
||||
@@ -243,12 +242,6 @@ class TestBackwardCompatibility:
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_footer_no_admin_link_when_oidc_disabled(self, client: TestClient) -> None:
|
||||
"""Test footer has no admin link when OIDC disabled."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/admin/"' not in response.text
|
||||
|
||||
|
||||
class TestConfigInjection:
|
||||
"""Test config injection values for OIDC state."""
|
||||
@@ -261,8 +254,8 @@ class TestConfigInjection:
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
assert config["user"]["name"] == "Admin User"
|
||||
assert config["is_admin"] is True
|
||||
assert config["is_member"] is True
|
||||
assert "admin" in config["roles"]
|
||||
assert "member" in config["roles"]
|
||||
|
||||
def test_member_session_config(
|
||||
self, client_with_oidc_member_session: TestClient
|
||||
@@ -272,8 +265,8 @@ class TestConfigInjection:
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
assert config["user"]["name"] == "Member User"
|
||||
assert config["is_admin"] is False
|
||||
assert config["is_member"] is True
|
||||
assert "admin" not in config["roles"]
|
||||
assert "member" in config["roles"]
|
||||
|
||||
def test_no_session_config(self, client_with_oidc: TestClient) -> None:
|
||||
"""Test no session injects correct config values."""
|
||||
@@ -281,8 +274,7 @@ class TestConfigInjection:
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
assert config["user"] is None
|
||||
assert config["is_admin"] is False
|
||||
assert config["is_member"] is False
|
||||
assert config["roles"] == []
|
||||
|
||||
|
||||
class TestStripUserinfo:
|
||||
|
||||
Reference in New Issue
Block a user