Replace Member model with UserProfile-backed data

Remove the static Member model/table, CRUD API, YAML seed files, and
admin UI. Replace with UserProfile-driven members page that reads roles
from OIDC identity provider. Key changes:

- Drop members table, add roles column to user_profiles (Alembic migration)
- Add GET /api/v1/user/profiles (paginated, no user_id exposed)
- Add GET /api/v1/user/profile/me (auto-creates profile for current user)
- Replace member_id node tag filter with adopted_by (profile UUID)
- Members page now shows profiles grouped by operator/member roles
- Profile page supports public view (/profile/:id) and owner edit (/profile)
- Node detail page shows adoption card side-by-side with public key card
- Auto-create user profile during OIDC login callback
- Hide Adopted Nodes section for non-operator/admin users
- Add member since date to profile cards
- Add role badges and adopted node badges to member tiles
- Add antenna/users icons to Members page group headers
This commit is contained in:
Louis King
2026-04-30 20:57:26 +01:00
parent 378f04d183
commit d37b30a05b
50 changed files with 1734 additions and 2113 deletions
+11 -23
View File
@@ -145,16 +145,14 @@ class Node(Base, UUIDMixin, TimestampMixin):
tags: Mapped[list["NodeTag"]] = relationship(back_populates="node", cascade="all, delete-orphan")
class Member(Base, UUIDMixin, TimestampMixin):
"""Network member model - stores info about network operators."""
__tablename__ = "members"
class UserProfile(Base, UUIDMixin, TimestampMixin):
"""UserProfile model for authenticated OIDC users."""
__tablename__ = "user_profiles"
name: Mapped[str] = mapped_column(String(255), nullable=False)
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)
role: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
contact: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
public_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
roles: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
```
### FastAPI Routes
@@ -266,12 +264,10 @@ meshcore-hub/
│ │ ├── hash_utils.py # Hash utility functions
│ │ ├── 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/
@@ -281,7 +277,6 @@ meshcore-hub/
│ │ ├── letsmesh_decoder.py # Native Python packet decoder
│ │ ├── letsmesh_normalizer.py # LetsMesh upload topic normalizer
│ │ ├── tag_import.py # Tag import from YAML
│ │ ├── member_import.py # Member import from YAML
│ │ ├── handlers/ # Event handlers
│ │ └── webhook.py # Webhook dispatcher
│ ├── api/
@@ -291,8 +286,7 @@ meshcore-hub/
│ │ ├── dependencies.py
│ │ ├── metrics.py # Prometheus metrics endpoint
│ │ └── routes/ # API routes
│ │ ├── members.py # Member CRUD endpoints
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
│ │ └── ...
│ └── web/
@@ -342,14 +336,12 @@ meshcore-hub/
│ └── meshcore-hub-update@.timer # Auto-update timer
├── example/
│ ├── seed/ # Example seed data files
│ │ ── node_tags.yaml # Example node tags
│ │ └── members.yaml # Example network members
│ │ ── node_tags.yaml # Example node tags
│ └── content/ # Example custom content
│ ├── pages/ # Example custom pages
│ └── media/ # Example media files
├── seed/ # Seed data directory (SEED_HOME)
── node_tags.yaml # Node tags for import
│ └── members.yaml # Network members for import
── node_tags.yaml # Node tags for import
├── data/ # Runtime data (gitignored, DATA_HOME default)
│ └── collector/ # Collector data
│ └── meshcore.db # SQLite database
@@ -410,7 +402,6 @@ Node tags are flexible key-value pairs that allow custom metadata to be attached
|---------|-------------|-------|
| `name` | Node display name | Used as the primary display name throughout the UI (overrides the advertised name) |
| `description` | Short description | Displayed as supplementary text under the node name |
| `member_id` | Member identifier reference | Links the node to a network member (matches `member_id` in Members table) |
| `lat` | GPS latitude override | Overrides node-reported latitude for map display |
| `lon` | GPS longitude override | Overrides node-reported longitude for map display |
| `elevation` | GPS elevation override | Overrides node-reported elevation |
@@ -419,7 +410,6 @@ Node tags are flexible key-value pairs that allow custom metadata to be attached
**Important Notes:**
- All tags are optional - nodes can function without any tags
- Tag keys are case-sensitive
- The `member_id` tag should reference a valid `member_id` from the Members table
## Testing Guidelines
@@ -679,8 +669,7 @@ The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does
**Seed Data (`SEED_HOME`)** - Contains initial data files for database seeding:
```
${SEED_HOME}/
── node_tags.yaml # Node tags (keyed by public_key)
└── members.yaml # Network members list
── node_tags.yaml # Node tags (keyed by public_key)
```
**Custom Content (`CONTENT_HOME`)** - Custom pages and media for the web dashboard. See [docs/content.md](docs/content.md) for directory structure, frontmatter fields, and setup guide.
@@ -696,9 +685,8 @@ Services automatically create their subdirectories if they don't exist.
### Seeding
The database can be seeded with node tags and network members from YAML files in `SEED_HOME`:
The database can be seeded with node tags from YAML files in `SEED_HOME`:
- `node_tags.yaml` - Node tag definitions (keyed by public_key)
- `members.yaml` - Network member definitions
**Important:** Seeding is NOT automatic and must be run explicitly. This prevents seed files from overwriting user changes made via the admin UI.
+1 -1
View File
@@ -427,7 +427,7 @@ The web dashboard supports custom markdown pages and media files (including cust
## Seed Data
The database can be seeded with node tags and network members from YAML files. See [docs/seeding.md](docs/seeding.md) for format details, directory structure, and running the seed process.
The database can be seeded with node tags from YAML files. See [docs/seeding.md](docs/seeding.md) for format details, directory structure, and running the seed process.
## API Documentation
@@ -0,0 +1,43 @@
"""add roles to user_profiles, drop members
Revision ID: a7eaa878e58b
Revises: 72b6578ee3bf
Create Date: 2026-04-30 09:24:58.073046+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a7eaa878e58b"
down_revision: Union[str, None] = "72b6578ee3bf"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("user_profiles", schema=None) as batch_op:
batch_op.add_column(sa.Column("roles", sa.Text(), nullable=True))
op.drop_table("members")
def downgrade() -> None:
op.create_table(
"members",
sa.Column("id", sa.VARCHAR(36), nullable=False),
sa.Column("member_id", sa.VARCHAR(100), nullable=False),
sa.Column("name", sa.VARCHAR(255), nullable=False),
sa.Column("callsign", sa.VARCHAR(20), nullable=True),
sa.Column("role", sa.VARCHAR(100), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("contact", sa.VARCHAR(255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("member_id"),
)
with op.batch_alter_table("user_profiles", schema=None) as batch_op:
batch_op.drop_column("roles")
+2 -2
View File
@@ -336,7 +336,7 @@ services:
command: ["db", "upgrade"]
# ==========================================================================
# Seed Data - Import node_tags.yaml and members.yaml from SEED_HOME
# Seed Data - Import node_tags.yaml from SEED_HOME
# NOTE: This is NOT run automatically. Use --profile seed to run explicitly.
# Since tags are now managed via the admin UI, automatic seeding would
# overwrite user changes.
@@ -357,7 +357,7 @@ services:
- DATA_HOME=/data
- SEED_HOME=/seed
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# Imports both node_tags.yaml and members.yaml if they exist
# Imports node_tags.yaml if it exists
command: ["collector", "seed"]
# ==========================================================================
@@ -0,0 +1,700 @@
# Replace Members with UserProfile — Implementation Plan
**Date:** 2026-04-30
**Status:** Approved
## Overview
Remove the static `Member` model/schema/routes and replace the Members page with a UserProfile-backed page. UserProfiles are auto-created from OIDC authentication and linked to nodes via the `UserProfileNode` adoption table. The new page shows two groups — Operators and Members — based on persisted OIDC roles. The `member_id` tag system used for node→member associations is replaced with UserProfile adoption data throughout nodes, advertisements, and map pages.
## Decisions
1. **Roles stored on `UserProfile`** — A `roles` column (comma-separated string) is added to `user_profiles`, updated from `X-User-Roles` on each authenticated request. IdP role changes reflected on next profile access.
2. **Profile UUID in public URLs** — Public profile links use the profile `id` (UUID), not the OIDC `user_id` subject identifier, to avoid exposing IdP identifiers.
3. **Full Members removal** — Delete `Member` model, schemas, routes, admin page, seed import, CLI commands, tests. Drop `members` table via Alembic migration. No backward compatibility.
4. **`FEATURE_MEMBERS` kept** — The env var still controls nav visibility, now backed by UserProfile data.
5. **`/profile/:id` for public viewing** — Extend the existing profile route. Own profile (`/profile`) remains editable; public view (`/profile/:id`) is read-only (editable only for the owner).
6. **Shared `_get_or_create_profile` utility** — Deduplicated from `user_profiles.py` and `adoptions.py` into a single module.
7. **`member_id` tag system replaced with UserProfile adoptions** — The `member_id` tag on nodes (used for filtering and display in nodes, ads, and map pages) is replaced with UserProfile adoption data. Member filter dropdowns become "Adopted by user" filters.
8. **Separate public profile schema** — A `UserProfilePublic` schema omits `user_id` from public-facing API responses.
9. **Role names exposed in SPA config** — The server-side `OIDC_ROLE_OPERATOR` / `OIDC_ROLE_MEMBER` config values are exposed to the SPA via `window.__APP_CONFIG__.role_names` so the frontend can correctly group profiles by role.
10. **Prometheus metrics replaced**`meshcore_members_total` replaced with `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`.
## Terminology
| Term | Meaning |
|---|---|
| Profile UUID | `user_profiles.id` — the UUID primary key, used in public URLs |
| `user_id` | OIDC subject identifier (`sub` claim) — internal, not exposed in public APIs |
| Operator | User with the `operator` role (from `OIDC_ROLE_OPERATOR` config) |
| Member | User with the `member` role (from `OIDC_ROLE_MEMBER` config) |
| Adoption | A `UserProfileNode` row linking a user to a node |
| `member_id` tag | Legacy node tag referencing a Member slug — **removed** in this plan |
## Current State
### Member Model (`members` table)
```
members
├── id UUID PK
├── member_id String(100) UNIQUE — human-readable slug (e.g., 'walshie86')
├── name String(255)
├── callsign String(20) nullable
├── role String(100) nullable
├── description Text nullable
├── contact String(255) nullable
├── created_at DateTime
└── updated_at DateTime
```
Nodes linked indirectly via `member_id` tag on `NodeTag`.
### UserProfile Model (`user_profiles` table)
```
user_profiles
├── id UUID PK
├── user_id String(255) UNIQUE — OIDC subject
├── name String(255) nullable
├── callsign String(20) nullable
├── created_at DateTime
└── updated_at DateTime
```
**Missing:** `roles` column.
Nodes linked via `UserProfileNode` join table (adoptions).
### `member_id` Tag Usage (to be replaced)
The `member_id` tag on nodes is used for filtering and display across the codebase:
| Location | Usage |
|----------|-------|
| `api/routes/nodes.py:44` | `member_id` query filter param |
| `api/routes/advertisements.py:53` | Same `member_id` query filter |
| `web/app.py:658-740` | Map data endpoint fetches `/api/v1/members`, builds lookup, populates `owner` |
| `web/static/js/spa/pages/nodes.js` | Member filter dropdown, member name on node rows |
| `web/static/js/spa/pages/advertisements.js` | Same member filter and display |
| `web/static/js/spa/pages/map.js` | Member filter dropdown, filters by `node.member_id` |
| `seed/node_tags.yaml` | 18 entries with `member_id` tags |
### API Endpoints
| Endpoint | Current Auth | Notes |
|----------|-------------|-------|
| `GET /api/v1/members` | `RequireRead` | List all members — **to be removed** |
| `GET /api/v1/members/{id}` | `RequireRead` | Get member by UUID — **to be removed** |
| `POST /api/v1/members` | `RequireAdmin` | Create member — **to be removed** |
| `PUT /api/v1/members/{id}` | `RequireAdmin` | Update member — **to be removed** |
| `DELETE /api/v1/members/{id}` | `RequireAdmin` | Delete member — **to be removed** |
| `GET /api/v1/user/profile/{user_id}` | `RequireUserOwner` | Owner-only, auto-creates — **to be updated** |
| `PUT /api/v1/user/profile/{user_id}` | `RequireUserOwner` | Owner-only update — **to be updated** |
| `POST /api/v1/adoptions` | `RequireOperatorOrAdmin` | Adopt a node — unchanged |
| `DELETE /api/v1/adoptions/{pk}` | `RequireOperatorOrAdmin` | Release a node — unchanged |
### Frontend Routes
| Route | Page | Notes |
|-------|------|-------|
| `/members` | `members.js` | Public member listing — **to be rewritten** |
| `/admin/members` | `admin/members.js` | Admin CRUD — **to be removed** |
| `/profile` | `profile.js` | Own profile (editable) — **to be extended** |
---
## Implementation
### Phase 1: Database & Model Changes
#### 1.1 Add `roles` column to `UserProfile` model
**File:** `src/meshcore_hub/common/models/user_profile.py`
Add column:
```python
from sqlalchemy import String, Text
roles: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
default=None,
)
```
Add helper property:
```python
@property
def role_list(self) -> list[str]:
if not self.roles:
return []
return [r.strip() for r in self.roles.split(",") if r.strip()]
```
#### 1.2 Alembic migration
```bash
source .venv/bin/activate
meshcore-hub db revision --autogenerate -m "add roles to user_profiles, drop members"
```
Edit the generated migration to:
- Add `roles` column (nullable text) to `user_profiles`
- Drop `members` table
```bash
meshcore-hub db upgrade
```
### Phase 2: Shared Utility Refactor
#### 2.1 Extract `_get_or_create_profile`
**File:** `src/meshcore_hub/api/profile_utils.py` (new)
Create a shared helper module. The helper:
1. Looks up `UserProfile` by `user_id`
2. If not found, creates one with `name` from `X-User-Name` header
3. **Updates `roles`** from `X-User-Roles` header on every call
4. Returns the profile
```python
def get_or_create_profile(
session: DbSession,
user_id: str,
request: Request,
) -> UserProfile:
query = select(UserProfile).where(UserProfile.user_id == user_id)
profile = session.execute(query).scalar_one_or_none()
if not profile:
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
profile = UserProfile(user_id=user_id, name=idp_name)
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
profile.roles = roles_header or None
session.add(profile)
session.commit()
session.refresh(profile)
return profile
```
#### 2.2 Update consumers
**Files:** `api/routes/user_profiles.py`, `api/routes/adoptions.py`
- Remove local `_get_or_create_profile` from both files
- Import shared helper from `api/profile_utils.py`
- Update all call sites
### Phase 3: API Endpoint Changes
#### 3.1 New Pydantic schemas
**File:** `src/meshcore_hub/common/schemas/user_profiles.py`
```python
class UserProfileRead(BaseModel):
id: str
user_id: str
name: Optional[str] = None
callsign: Optional[str] = None
roles: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class UserProfilePublic(BaseModel):
"""Public-facing schema — omits user_id for privacy."""
id: str
name: Optional[str] = None
callsign: Optional[str] = None
roles: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class UserProfileListItem(BaseModel):
id: str
name: Optional[str] = None
callsign: Optional[str] = None
roles: list[str] = Field(default_factory=list)
node_count: int = 0
class UserProfileList(BaseModel):
items: list[UserProfileListItem]
total: int
limit: int
offset: int
class UserProfilePublicWithNodes(UserProfilePublic):
"""Public profile view with adopted nodes."""
nodes: list[AdoptedNodeRead] = Field(default_factory=list)
```
Update `UserProfileWithNodes` to add `roles` field (inherits from `UserProfileRead`).
#### 3.2 New public list endpoint
**File:** `src/meshcore_hub/api/routes/user_profiles.py`
```
GET /api/v1/user/profiles
```
- **Auth:** `RequireRead` (open if no API keys configured)
- **Query params:** `limit` (default 100, max 500), `offset` (default 0)
- **Response:** `UserProfileList` with `items`, `total`, `limit`, `offset`
- Each item includes: `id`, `name`, `callsign`, `roles` (parsed list), `node_count`
- No `user_id` exposed in list response (privacy)
- Ordered by `name` ascending
**Route ordering:** This route MUST be registered before `GET /profile/{profile_id}` to avoid FastAPI matching "profiles" as a `{profile_id}` value.
```python
@router.get("/profiles", response_model=UserProfileList)
async def list_profiles(
_: RequireRead,
session: DbSession,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> UserProfileList:
```
#### 3.3 Update detail endpoint — support profile UUID lookup
**File:** `src/meshcore_hub/api/routes/user_profiles.py`
Change `GET /api/v1/user/profile/{profile_id}` to:
- Accept profile UUID instead of `user_id`
- **Remove `_verify_owner` for GET** — anyone can view
- Auto-create only when the caller is the authenticated owner
- Return `UserProfilePublicWithNodes` (public schema, no `user_id`)
- For the owner's own request, still return `UserProfileWithNodes` (includes `user_id`)
Two lookup strategies:
1. If authenticated caller's `user_id` matches the profile's `user_id` → auto-create, return full profile
2. Otherwise → lookup by profile UUID, return public view (404 if not found)
Auth dependency becomes optional (allow unauthenticated access).
#### 3.4 Update PUT endpoint
**File:** `src/meshcore_hub/api/routes/user_profiles.py`
Change `PUT /api/v1/user/profile/{profile_id}` to:
- Accept profile UUID
- Keep owner + admin write restriction
- Lookup profile by UUID, verify `profile.user_id == caller_id` (or admin)
#### 3.5 Replace `member_id` filter in nodes/advertisements API routes
**File:** `src/meshcore_hub/api/routes/nodes.py`
- Remove `member_id` query parameter (line 44)
- Add optional `adopted_by` query parameter — filters nodes that are adopted by a specific user profile (by profile UUID)
- Implementation: join `UserProfileNode` and filter by `user_profile_id`
**File:** `src/meshcore_hub/api/routes/advertisements.py`
- Remove `member_id` query parameter (line 53-54)
- Add optional `adopted_by` query parameter — same approach as nodes
### Phase 4: Web App Updates
#### 4.1 Update endpoint access map
**File:** `src/meshcore_hub/web/app.py`
```python
# Remove:
"v1/members": {
"GET": _OPEN,
"POST": admin,
"PUT": admin,
"DELETE": admin,
},
# Add:
"v1/user/profiles": {
"GET": _OPEN,
},
```
Update existing `v1/user/profile` entry to allow unauthenticated GET (remove role requirement for GET, keep for PUT).
#### 4.2 Update robots.txt / sitemap
**File:** `src/meshcore_hub/web/app.py`
- Keep `/members` in feature paths and sitemap (the page still exists, just backed by UserProfile data)
#### 4.3 Update map data endpoint
**File:** `src/meshcore_hub/web/app.py` (lines 658-740)
The `/map/data` endpoint currently:
1. Fetches all members from `/api/v1/members`
2. Builds a lookup by `member_id`
3. For each node, reads `member_id` tag and resolves to member info
4. Populates `owner` and `member_id` on each map node
Replace with:
1. Fetch all user profiles from `/api/v1/user/profiles`
2. Fetch all adoptions (or include adopted_by in node data from the nodes API)
3. For each node, resolve adoption to user profile
4. Populate `owner` on each map node (with `id`, `name`, `callsign` from profile)
5. Remove `member_id` field from map node objects
6. Remove `members_list` from map response (replace with `profiles` if needed for filter dropdown)
#### 4.4 Expose role names in SPA config
**File:** `src/meshcore_hub/web/app.py` (`_build_config_json`)
Add role names to the SPA config so the frontend can match profile roles to the configured operator/member/admin role values:
```python
config["role_names"] = {
"operator": settings.oidc_role_operator,
"member": settings.oidc_role_member,
"admin": settings.oidc_role_admin,
}
```
### Phase 5: Remove Members Infrastructure
#### 5.1 Delete files
| File | Description |
|---|---|
| `src/meshcore_hub/common/models/member.py` | Member SQLAlchemy model |
| `src/meshcore_hub/common/schemas/members.py` | Member Pydantic schemas |
| `src/meshcore_hub/api/routes/members.py` | Member API routes |
| `src/meshcore_hub/collector/member_import.py` | Member YAML import |
| `src/meshcore_hub/web/static/js/spa/pages/admin/members.js` | Admin members page |
| `seed/members.yaml` | Production member seed data |
| `example/seed/members.yaml` | Example member seed data |
#### 5.2 Update `__init__` exports
**File:** `src/meshcore_hub/common/models/__init__.py`
- Remove `Member` import and export
**File:** `src/meshcore_hub/common/schemas/__init__.py`
- Remove `MemberCreate`, `MemberUpdate`, `MemberRead`, `MemberList` imports and exports
**File:** `src/meshcore_hub/api/routes/__init__.py`
- Remove `members_router` import and `include_router` call
#### 5.3 Update `collector/cli.py`
- Remove `import_members_cmd` (`import-members` command, lines 539-613)
- Remove member import from `seed` command (lines 413-431 in `_run_seed_import`)
- Remove `--members` flag from `truncate` command (line 705)
- Remove `--all` member inclusion from `truncate` command (line 794)
- Remove `Member` import from `truncate` command (line 851)
- Remove member truncation logic (lines 867-870)
#### 5.4 Update `common/config.py`
- Remove `members_file` property (lines 184-189)
#### 5.5 Update `api/metrics.py`
- Remove `Member` import (line 17)
- Replace `meshcore_members_total` gauge (lines 273-280) with:
```python
user_profiles_total = Gauge(
"meshcore_user_profiles_total",
"Total number of user profiles",
registry=registry,
)
count = session.execute(select(func.count(UserProfile.id))).scalar() or 0
user_profiles_total.set(count)
user_profiles_by_role = Gauge(
"meshcore_user_profiles_by_role",
"Number of user profiles by role",
["role"],
registry=registry,
)
for row in session.execute(select(UserProfile.roles, func.count(UserProfile.id)).group_by(UserProfile.roles)):
if row.roles:
for role in row.roles.split(","):
role = role.strip()
if role:
user_profiles_by_role.labels(role=role).set(
session.execute(
select(func.count(UserProfile.id)).where(UserProfile.roles.contains(role))
).scalar() or 0
)
```
#### 5.6 Update `web/templates/spa.html`
- Remove admin Members nav link from admin section (both mobile and desktop menus)
#### 5.7 Update admin index page
**File:** `src/meshcore_hub/web/static/js/spa/pages/admin/index.js`
- Remove members admin card/link from the admin dashboard (lines 51-59)
#### 5.8 Update i18n
**File:** `src/meshcore_hub/web/static/locales/en.json`
Remove:
- `members.*` section
- `admin_members.*` section
- `admin.members_description` entry
Keep (used by the new UserProfile-backed members page):
- `entities.members` / `entities.member` — still used for nav link and page title
Add:
```json
{
"members_page": {
"operators": "Operators",
"members": "Members",
"node_count": "{{count}} nodes",
"empty_state": "No members yet",
"empty_description": "Members will appear here once users log in and adopt nodes."
},
"user_profile": {
"view_profile": "View Profile",
"edit_profile": "Edit Profile",
"role_operator": "Operator",
"role_member": "Member"
}
}
```
#### 5.9 Remove `member_id` tags from seed data
**File:** `seed/node_tags.yaml`
- Remove all `member_id:` key-value entries (18 occurrences)
#### 5.10 Update `docker-compose.yml`
- Update seed service comment from "Imports both node_tags.yaml and members.yaml if they exist" to "Imports node_tags.yaml if it exists" (line 360)
### Phase 6: Frontend — Replace member_id References
#### 6.1 Update nodes page
**File:** `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
- Remove fetch of `/api/v1/members` (line 61)
- Remove `member_id` query param handling (line 15)
- Remove `showMembers` flag and member filter dropdown (lines 22, 73-81)
- Remove `memberIdTag` lookup and member name display (lines 93-96, 125-128, 148)
- Remove "Member" column header from desktop table (line 198)
- Optionally: add `adopted_by` filter if desired (or skip for now)
#### 6.2 Update advertisements page
**File:** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
- Same pattern as nodes: remove `/api/v1/members` fetch, `member_id` param, member filter dropdown, member display
#### 6.3 Update map page
**File:** `src/meshcore_hub/web/static/js/spa/pages/map.js`
- Remove member filter dropdown (lines 209-221)
- Remove `memberFilter` logic (line 279)
- Remove `node.member_id` filter comparison
- The map data endpoint will now provide `owner` from UserProfile adoptions instead of `member_id` tags
- Update owner display on map popups if applicable
### Phase 7: Frontend — Members Page & Profile Page
#### 7.1 Rewrite `members.js`
**File:** `src/meshcore_hub/web/static/js/spa/pages/members.js`
- Fetch from `GET /api/v1/user/profiles`
- Read `config.role_names` to determine which role values map to operator/member
- Split profiles into two groups:
- **Operators** — where `roles` includes the operator role name, sorted alphabetically by `name`
- **Members** — where `roles` includes the member role name (and not operator), sorted alphabetically by `name`
- Each tile shows: `name`, `callsign` badge, `node_count` ("N nodes")
- Tiles link to `/profile/${profile.id}`
- Section headers: "Operators" and "Members"
- Empty state: simple "No members yet" message
- Layout: same responsive grid (`grid-cols-1 md:grid-cols-2 lg:grid-cols-3`)
#### 7.2 Update `app.js` routes
**File:** `src/meshcore_hub/web/static/js/spa/app.js`
- Remove `adminMembers` lazy import and `/admin/members` route (lines 26, 96)
- Update `/profile` route to also handle `/profile/:id`:
```javascript
router.addRoute('/profile', pageHandler(pages.profile));
router.addRoute('/profile/:id', pageHandler(pages.profile));
```
#### 7.3 Update `profile.js`
**File:** `src/meshcore_hub/web/static/js/spa/pages/profile.js`
- If `params.id` is present → public view mode:
- Fetch `GET /api/v1/user/profile/{id}`
- Render read-only: name, callsign, role badges, adopted nodes list
- If the viewer owns this profile, show "Edit Profile" link to `/profile`
- If no `params.id` → current behavior (own profile, editable)
- Role badges rendered from `profile.roles` list, displayed with labels from `config.role_names`
### Phase 8: Tests
#### 8.1 Delete test files
| File | Description |
|---|---|
| `tests/test_api/test_members.py` | Member API route tests |
#### 8.2 Delete test fixtures and helpers
| File | Fixture/Helper | Action |
|---|---|---|
| `tests/test_api/conftest.py` | `sample_member` (line 288) | Delete — creates `Member()` instance |
| `tests/test_api/conftest.py` | `sample_node_with_member_tag` (line 409) | Delete — creates `member_id` tag node |
| `tests/test_web/conftest.py` | `GET:/api/v1/members` mock response (line 154) | Delete |
| `tests/test_web/conftest.py` | `mock_http_client_with_members` (line 466) | Delete |
| `tests/test_web/conftest.py` | `web_app_with_members` (line 548) | Delete |
| `tests/test_web/conftest.py` | `client_with_members` (line 568) | Delete |
| `tests/test_web/conftest.py` | `"members": True` in features (line 20) | Keep — feature flag still used |
#### 8.3 Update test files — API tests
| File | Change |
|---|---|
| `tests/test_api/test_user_profiles.py` | Add: `GET /profiles` tests (list, pagination, roles, node_count). Add: public `GET /profile/{id}` by UUID. Add: role persistence tests. Update: existing tests for UUID-based lookup, public access. Verify `user_id` not in list response. |
| `tests/test_api/test_nodes.py` | Remove `test_filter_by_member_id` (line 171). Add or update for `adopted_by` filter if implemented. |
| `tests/test_api/test_advertisements.py` | Remove `test_filter_by_member_id` (line 217). Add or update for `adopted_by` filter if implemented. |
| `tests/test_api/test_metrics.py` | Remove `meshcore_members_total` assertion (line 59). Remove `test_members_total_reflects_database` (line 150). Add tests for `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`. |
#### 8.4 Update test files — Web tests
| File | Change |
|---|---|
| `tests/test_web/test_members.py` | **Delete** — tests the old member page |
| `tests/test_web/test_features.py` | Keep members feature flag tests (feature still exists). Update any tests that assert member-related content in sitemap/nav. |
| `tests/test_web/test_advertisements.py` | Remove `test_advertisements_with_member_filter` (line 49) and related assertions. |
| `tests/test_web/test_oidc.py` | Update `test_post_blocked_for_member` (line 196) — uses `POST /api/v1/members` which is being removed. Change to use a different write endpoint. |
#### 8.5 Update test files — Common tests
| File | Change |
|---|---|
| `tests/test_common/test_config.py` | Remove `test_members_file` (line 61) — property being deleted |
| `tests/test_common/test_i18n.py` | Remove `"members"` from required sections (line 110). Remove `"admin_members"` from required sections (line 114). Keep `entities.members` assertion if it remains in en.json. |
#### 8.6 Run commands
```bash
source .venv/bin/activate
pytest tests/test_api/test_user_profiles.py -v
pytest tests/test_api/ -v
pytest tests/test_web/ -v
pytest tests/test_common/ -v
pre-commit run --all-files
```
### Phase 9: Documentation
#### 9.1 Update files
| File | Changes |
|---|---|
| `SCHEMAS.md` | Remove Member schemas, add `roles` to UserProfile schemas, document new endpoints (`GET /profiles`, updated `GET /profile/{id}`), remove `member_id` query param docs |
| `docs/upgrading.md` | Breaking change: Member model/table removed, `member_id` tags no longer supported, seed files changed, Prometheus metric renamed |
| `docs/seeding.md` | Remove member seeding section, remove `member_id` tag from seed format examples |
| `AGENTS.md` | Update project structure (remove Member files), update env vars (remove member refs), update database conventions, remove `member_id` from standard node tags table, add `roles` to UserProfile model |
| `README.md` | Remove member-related references, update feature descriptions |
---
## File Change Summary
| # | File | Action | Phase |
|---|------|--------|-------|
| 1 | `common/models/user_profile.py` | Modify — add `roles` column | 1 |
| 2 | `alembic/versions/*.py` | Create — add roles, drop members | 1 |
| 3 | `api/profile_utils.py` | **Create** — shared `get_or_create_profile` | 2 |
| 4 | `api/routes/user_profiles.py` | Modify — shared util, new endpoints, public access, route ordering | 3 |
| 5 | `api/routes/adoptions.py` | Modify — shared util | 2 |
| 6 | `common/schemas/user_profiles.py` | Modify — add `roles`, `UserProfilePublic`, list schemas | 3 |
| 7 | `api/routes/nodes.py` | Modify — replace `member_id` filter with `adopted_by` | 3 |
| 8 | `api/routes/advertisements.py` | Modify — replace `member_id` filter with `adopted_by` | 3 |
| 9 | `web/app.py` | Modify — endpoint access map, sitemap, map data endpoint, SPA config role names | 4 |
| 10 | `common/models/member.py` | **Delete** | 5 |
| 11 | `common/schemas/members.py` | **Delete** | 5 |
| 12 | `api/routes/members.py` | **Delete** | 5 |
| 13 | `collector/member_import.py` | **Delete** | 5 |
| 14 | `web/static/js/spa/pages/admin/members.js` | **Delete** | 5 |
| 15 | `seed/members.yaml` | **Delete** | 5 |
| 16 | `example/seed/members.yaml` | **Delete** | 5 |
| 17 | `common/models/__init__.py` | Modify — remove Member export | 5 |
| 18 | `common/schemas/__init__.py` | Modify — remove member schemas | 5 |
| 19 | `api/routes/__init__.py` | Modify — remove members router | 5 |
| 20 | `collector/cli.py` | Modify — remove member CLI commands | 5 |
| 21 | `common/config.py` | Modify — remove `members_file` | 5 |
| 22 | `api/metrics.py` | Modify — replace members metric with profiles metric | 5 |
| 23 | `web/templates/spa.html` | Modify — remove admin members nav | 5 |
| 24 | `web/static/js/spa/pages/admin/index.js` | Modify — remove members card | 5 |
| 25 | `web/static/locales/en.json` | Modify — remove member keys, add new keys | 5 |
| 26 | `seed/node_tags.yaml` | Modify — remove `member_id` tags | 5 |
| 27 | `docker-compose.yml` | Modify — update seed service comment | 5 |
| 28 | `web/static/js/spa/pages/nodes.js` | Modify — remove member filter/display | 6 |
| 29 | `web/static/js/spa/pages/advertisements.js` | Modify — remove member filter/display | 6 |
| 30 | `web/static/js/spa/pages/map.js` | Modify — remove member filter, use adoption data | 6 |
| 31 | `web/static/js/spa/pages/members.js` | **Rewrite** — UserProfile-backed | 7 |
| 32 | `web/static/js/spa/app.js` | Modify — remove admin members, add profile/:id | 7 |
| 33 | `web/static/js/spa/pages/profile.js` | Modify — public view mode | 7 |
| 34 | `tests/test_api/test_members.py` | **Delete** | 8 |
| 35 | `tests/test_web/test_members.py` | **Delete** | 8 |
| 36 | `tests/test_api/conftest.py` | Modify — remove member fixtures | 8 |
| 37 | `tests/test_web/conftest.py` | Modify — remove member mock fixtures | 8 |
| 38 | `tests/test_api/test_user_profiles.py` | Modify — new endpoint tests | 8 |
| 39 | `tests/test_api/test_nodes.py` | Modify — remove member_id filter test | 8 |
| 40 | `tests/test_api/test_advertisements.py` | Modify — remove member_id filter test | 8 |
| 41 | `tests/test_api/test_metrics.py` | Modify — replace member metric tests | 8 |
| 42 | `tests/test_web/test_features.py` | Modify — update member feature tests | 8 |
| 43 | `tests/test_web/test_advertisements.py` | Modify — remove member filter test | 8 |
| 44 | `tests/test_web/test_oidc.py` | Modify — use different endpoint for POST test | 8 |
| 45 | `tests/test_common/test_config.py` | Modify — remove members_file test | 8 |
| 46 | `tests/test_common/test_i18n.py` | Modify — remove member section tests | 8 |
| 47 | `SCHEMAS.md` | Modify | 9 |
| 48 | `docs/upgrading.md` | Modify | 9 |
| 49 | `docs/seeding.md` | Modify | 9 |
| 50 | `AGENTS.md` | Modify | 9 |
| 51 | `README.md` | Modify | 9 |
---
## Execution Order
1. Phase 1: Database & model changes (model + migration)
2. Phase 2: Shared utility refactor
3. Phase 3: API endpoint changes (new endpoints, schema changes, replace member_id filters)
4. Phase 4: Web app updates (access map, map data, SPA config)
5. Phase 5: Remove Members infrastructure (delete files, update exports, metrics, CLI, seed data)
6. Phase 6: Frontend — replace member_id references (nodes, ads, map pages)
7. Phase 7: Frontend — members page rewrite & profile page extension
8. Phase 8: Tests
9. Phase 9: Documentation
10. `pre-commit run --all-files` + `pytest`
@@ -0,0 +1,156 @@
# Replace Members with UserProfile — Task Checklist
**Plan:** `docs/plans/20260430-0805-members-refactor/plan.md`
**Status:** Not Started
---
## Phase 1: Database & Model Changes
- [ ] **1.1** `src/meshcore_hub/common/models/user_profile.py` — Add `roles: Mapped[Optional[str]]` column (nullable `Text`) and `role_list` property that parses comma-separated string into `list[str]`
- [ ] **1.2** Generate Alembic migration — `meshcore-hub db revision --autogenerate -m "add roles to user_profiles, drop members"`, then edit to: add `roles` column to `user_profiles`, drop `members` table
- [ ] **1.3** Run `meshcore-hub db upgrade` and verify migration applies cleanly
## Phase 2: Shared Utility Refactor
- [ ] **2.1** Create `src/meshcore_hub/api/profile_utils.py` — Shared `get_or_create_profile()` helper: looks up by `user_id`, creates if missing (with name from `X-User-Name`), updates `roles` from `X-User-Roles` on every call
- [ ] **2.2** `src/meshcore_hub/api/routes/user_profiles.py` — Remove local `_get_or_create_profile`, import shared helper from `profile_utils.py`
- [ ] **2.3** `src/meshcore_hub/api/routes/adoptions.py` — Remove local `_get_or_create_profile`, import shared helper from `profile_utils.py`
## Phase 3: API Endpoint Changes
- [ ] **3.1** `src/meshcore_hub/common/schemas/user_profiles.py` — Add `roles` field to `UserProfileRead`, create `UserProfilePublic` (omits `user_id`), `UserProfileListItem` (with `node_count`), `UserProfileList`, and `UserProfilePublicWithNodes`; update `UserProfileWithNodes` to include `roles`
- [ ] **3.2** `src/meshcore_hub/api/routes/user_profiles.py` — Add `GET /profiles` endpoint (list all profiles, `RequireRead`, paginated, ordered by `name`, returns `UserProfileList` with `node_count`); register BEFORE `GET /profile/{profile_id}` to avoid route shadowing
- [ ] **3.3** `src/meshcore_hub/api/routes/user_profiles.py` — Update `GET /profile/{profile_id}` to accept profile UUID (not `user_id`), remove `_verify_owner` for GET, allow unauthenticated access, return `UserProfilePublicWithNodes` for public or `UserProfileWithNodes` for owner
- [ ] **3.4** `src/meshcore_hub/api/routes/user_profiles.py` — Update `PUT /profile/{profile_id}` to accept profile UUID, lookup by UUID, verify owner or admin
- [ ] **3.5** `src/meshcore_hub/api/routes/nodes.py` — Remove `member_id` query parameter (line 44), add optional `adopted_by` query parameter filtering by `UserProfileNode.user_profile_id`
- [ ] **3.6** `src/meshcore_hub/api/routes/advertisements.py` — Remove `member_id` query parameter (lines 53-54), add optional `adopted_by` query parameter
## Phase 4: Web App Updates
- [ ] **4.1** `src/meshcore_hub/web/app.py` — Update endpoint access map: remove `v1/members` entry, add `v1/user/profiles` with GET open; update `v1/user/profile` to allow unauthenticated GET
- [ ] **4.2** `src/meshcore_hub/web/app.py` — Update map data endpoint (lines 658-740): replace `/api/v1/members` fetch + `member_id` tag lookup with `/api/v1/user/profiles` + adoption resolution; populate `owner` from profile data; remove `member_id` field from map node objects; replace `members_list` with `profiles` if needed
- [ ] **4.3** `src/meshcore_hub/web/app.py` — In `_build_config_json()`: add `role_names` dict with `operator`, `member`, `admin` values from OIDC settings
## Phase 5: Remove Members Infrastructure
- [ ] **5.1** Delete `src/meshcore_hub/common/models/member.py`
- [ ] **5.2** Delete `src/meshcore_hub/common/schemas/members.py`
- [ ] **5.3** Delete `src/meshcore_hub/api/routes/members.py`
- [ ] **5.4** Delete `src/meshcore_hub/collector/member_import.py`
- [ ] **5.5** Delete `src/meshcore_hub/web/static/js/spa/pages/admin/members.js`
- [ ] **5.6** Delete `seed/members.yaml`
- [ ] **5.7** Delete `example/seed/members.yaml`
- [ ] **5.8** `src/meshcore_hub/common/models/__init__.py` — Remove `Member` import and export
- [ ] **5.9** `src/meshcore_hub/common/schemas/__init__.py` — Remove `MemberCreate`, `MemberUpdate`, `MemberRead`, `MemberList` imports and exports
- [ ] **5.10** `src/meshcore_hub/api/routes/__init__.py` — Remove `members_router` import and `include_router` call
- [ ] **5.11** `src/meshcore_hub/collector/cli.py` — Remove `import_members_cmd` (`import-members` command), remove member import from `seed` command, remove `--members` flag and member inclusion from `truncate` command, remove `Member` import and truncation logic
- [ ] **5.12** `src/meshcore_hub/common/config.py` — Remove `members_file` property (lines 184-189)
- [ ] **5.13** `src/meshcore_hub/api/metrics.py` — Remove `Member` import, replace `meshcore_members_total` gauge with `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`
- [ ] **5.14** `src/meshcore_hub/web/templates/spa.html` — Remove admin Members nav link from both mobile and desktop admin menus
- [ ] **5.15** `src/meshcore_hub/web/static/js/spa/pages/admin/index.js` — Remove members admin card/link (lines 51-59)
- [ ] **5.16** `src/meshcore_hub/web/static/locales/en.json` — Remove `members.*` and `admin_members.*` sections; add `members_page.*` and `user_profile.*` keys; keep `entities.members` / `entities.member`
- [ ] **5.17** `seed/node_tags.yaml` — Remove all 18 `member_id:` key-value entries
- [ ] **5.18** `docker-compose.yml` — Update seed service comment to remove members.yaml reference
## Phase 6: Frontend — Replace member_id References
- [ ] **6.1** `src/meshcore_hub/web/static/js/spa/pages/nodes.js` — Remove `/api/v1/members` fetch, `member_id` query param, `showMembers` flag, member filter dropdown, member name display, "Member" column header
- [ ] **6.2** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` — Remove same member-related code as nodes page: members fetch, filter dropdown, member display
- [ ] **6.3** `src/meshcore_hub/web/static/js/spa/pages/map.js` — Remove member filter dropdown, `memberFilter` logic, `node.member_id` filter comparison; update owner display from adoption data
## Phase 7: Frontend — Members Page & Profile Page
- [ ] **7.1** `src/meshcore_hub/web/static/js/spa/pages/members.js` — Rewrite: fetch `/api/v1/user/profiles`, split into Operators/Members groups using `config.role_names`, render tiles with name/callsign/node_count, link to `/profile/${id}`, responsive grid layout
- [ ] **7.2** `src/meshcore_hub/web/static/js/spa/app.js` — Remove `adminMembers` lazy import and `/admin/members` route; add `/profile/:id` route
- [ ] **7.3** `src/meshcore_hub/web/static/js/spa/pages/profile.js` — Add public view mode: when `params.id` present, fetch profile by UUID, render read-only view with role badges and adopted nodes; show "Edit Profile" link for owner; no `params.id` = current editable behavior
## Phase 8: Tests
- [ ] **8.1** Delete `tests/test_api/test_members.py`
- [ ] **8.2** Delete `tests/test_web/test_members.py`
- [ ] **8.3** `tests/test_api/conftest.py` — Remove `sample_member` fixture (line 288) and `sample_node_with_member_tag` fixture (line 409)
- [ ] **8.4** `tests/test_web/conftest.py` — Remove `GET:/api/v1/members` mock response (line 154), `mock_http_client_with_members` (line 466), `web_app_with_members` (line 548), `client_with_members` (line 568)
- [ ] **8.5** `tests/test_api/test_user_profiles.py` — Add tests for `GET /profiles` (list, pagination, roles, node_count, `user_id` not exposed); add public `GET /profile/{id}` by UUID tests; add role persistence tests; update existing tests for UUID-based lookup
- [ ] **8.6** `tests/test_api/test_nodes.py` — Remove `test_filter_by_member_id` (line 171); add `adopted_by` filter tests if implemented
- [ ] **8.7** `tests/test_api/test_advertisements.py` — Remove `test_filter_by_member_id` (line 217); add `adopted_by` filter tests if implemented
- [ ] **8.8** `tests/test_api/test_metrics.py` — Remove `meshcore_members_total` assertion (line 59) and `test_members_total_reflects_database` (line 150); add tests for `meshcore_user_profiles_total` and `meshcore_user_profiles_by_role`
- [ ] **8.9** `tests/test_web/test_features.py` — Keep members feature flag tests; update assertions for member content in sitemap/nav
- [ ] **8.10** `tests/test_web/test_advertisements.py` — Remove `test_advertisements_with_member_filter` (line 49)
- [ ] **8.11** `tests/test_web/test_oidc.py` — Update `test_post_blocked_for_member` (line 196) to use a different write endpoint instead of `POST /api/v1/members`
- [ ] **8.12** `tests/test_common/test_config.py` — Remove `test_members_file` (line 61)
- [ ] **8.13** `tests/test_common/test_i18n.py` — Remove `"members"` and `"admin_members"` from required sections; keep `entities.members` assertion
## Phase 9: Documentation
- [ ] **9.1** `SCHEMAS.md` — Remove Member schemas; add `roles` to UserProfile schemas; document `GET /profiles` and updated `GET /profile/{id}`; remove `member_id` query param docs
- [ ] **9.2** `docs/upgrading.md` — Add breaking change section: Member model/table removed, `member_id` tags no longer supported, seed files changed, Prometheus metric renamed
- [ ] **9.3** `docs/seeding.md` — Remove member seeding section; remove `member_id` tag from seed format examples
- [ ] **9.4** `AGENTS.md` — Update project structure (remove Member files), update env vars, remove `member_id` from standard node tags table, add `roles` to UserProfile model example
- [ ] **9.5** `README.md` — Remove member-related references, update feature descriptions
## Phase 10: Verification
- [ ] **10.1** Run `pytest tests/test_api/ -v`
- [ ] **10.2** Run `pytest tests/test_web/ -v`
- [ ] **10.3** Run `pytest tests/test_common/ -v`
- [ ] **10.4** Run `pytest` (full suite)
- [ ] **10.5** Run `pre-commit run --all-files`
---
## File Change Summary
| # | File | Action | Phase(s) |
|---|------|--------|----------|
| 1 | `src/meshcore_hub/common/models/user_profile.py` | Modify | 1 |
| 2 | `alembic/versions/*.py` | Create | 1 |
| 3 | `src/meshcore_hub/api/profile_utils.py` | Create | 2 |
| 4 | `src/meshcore_hub/api/routes/user_profiles.py` | Modify | 2, 3 |
| 5 | `src/meshcore_hub/api/routes/adoptions.py` | Modify | 2 |
| 6 | `src/meshcore_hub/common/schemas/user_profiles.py` | Modify | 3 |
| 7 | `src/meshcore_hub/api/routes/nodes.py` | Modify | 3 |
| 8 | `src/meshcore_hub/api/routes/advertisements.py` | Modify | 3 |
| 9 | `src/meshcore_hub/web/app.py` | Modify | 4 |
| 10 | `src/meshcore_hub/common/models/member.py` | Delete | 5 |
| 11 | `src/meshcore_hub/common/schemas/members.py` | Delete | 5 |
| 12 | `src/meshcore_hub/api/routes/members.py` | Delete | 5 |
| 13 | `src/meshcore_hub/collector/member_import.py` | Delete | 5 |
| 14 | `src/meshcore_hub/web/static/js/spa/pages/admin/members.js` | Delete | 5 |
| 15 | `seed/members.yaml` | Delete | 5 |
| 16 | `example/seed/members.yaml` | Delete | 5 |
| 17 | `src/meshcore_hub/common/models/__init__.py` | Modify | 5 |
| 18 | `src/meshcore_hub/common/schemas/__init__.py` | Modify | 5 |
| 19 | `src/meshcore_hub/api/routes/__init__.py` | Modify | 5 |
| 20 | `src/meshcore_hub/collector/cli.py` | Modify | 5 |
| 21 | `src/meshcore_hub/common/config.py` | Modify | 5 |
| 22 | `src/meshcore_hub/api/metrics.py` | Modify | 5 |
| 23 | `src/meshcore_hub/web/templates/spa.html` | Modify | 5 |
| 24 | `src/meshcore_hub/web/static/js/spa/pages/admin/index.js` | Modify | 5 |
| 25 | `src/meshcore_hub/web/static/locales/en.json` | Modify | 5 |
| 26 | `seed/node_tags.yaml` | Modify | 5 |
| 27 | `docker-compose.yml` | Modify | 5 |
| 28 | `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Modify | 6 |
| 29 | `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Modify | 6 |
| 30 | `src/meshcore_hub/web/static/js/spa/pages/map.js` | Modify | 6 |
| 31 | `src/meshcore_hub/web/static/js/spa/pages/members.js` | Rewrite | 7 |
| 32 | `src/meshcore_hub/web/static/js/spa/app.js` | Modify | 7 |
| 33 | `src/meshcore_hub/web/static/js/spa/pages/profile.js` | Modify | 7 |
| 34 | `tests/test_api/test_members.py` | Delete | 8 |
| 35 | `tests/test_web/test_members.py` | Delete | 8 |
| 36 | `tests/test_api/conftest.py` | Modify | 8 |
| 37 | `tests/test_web/conftest.py` | Modify | 8 |
| 38 | `tests/test_api/test_user_profiles.py` | Modify | 8 |
| 39 | `tests/test_api/test_nodes.py` | Modify | 8 |
| 40 | `tests/test_api/test_advertisements.py` | Modify | 8 |
| 41 | `tests/test_api/test_metrics.py` | Modify | 8 |
| 42 | `tests/test_web/test_features.py` | Modify | 8 |
| 43 | `tests/test_web/test_advertisements.py` | Modify | 8 |
| 44 | `tests/test_web/test_oidc.py` | Modify | 8 |
| 45 | `tests/test_common/test_config.py` | Modify | 8 |
| 46 | `tests/test_common/test_i18n.py` | Modify | 8 |
| 47 | `SCHEMAS.md` | Modify | 9 |
| 48 | `docs/upgrading.md` | Modify | 9 |
| 49 | `docs/seeding.md` | Modify | 9 |
| 50 | `AGENTS.md` | Modify | 9 |
| 51 | `README.md` | Modify | 9 |
+2 -34
View File
@@ -1,6 +1,6 @@
# Seed Data
The database can be seeded with node tags and network members from YAML files in the `SEED_HOME` directory (default: `./seed`).
The database can be seeded with node tags from YAML files in the `SEED_HOME` directory (default: `./seed`).
## Running the Seed Process
@@ -13,14 +13,12 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile seed up
This imports data from the following files (if they exist):
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
- `{SEED_HOME}/members.yaml` - Network member definitions
## Directory Structure
```
seed/ # SEED_HOME (seed data files)
── node_tags.yaml # Node tags for import
└── members.yaml # Network members for import
── node_tags.yaml # Node tags for import
data/ # DATA_HOME (runtime data)
└── collector/
@@ -45,7 +43,6 @@ Tags are keyed by public key in YAML format:
role: gateway
lat: 37.7749
lon: -122.4194
member_id: alice
fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210:
name: Oakland Repeater
@@ -63,32 +60,3 @@ Tag values can be:
```
Supported types: `string`, `number`, `boolean`
## Network Members
Network members represent the people operating nodes in your network. Members can optionally be linked to nodes via their public key.
### Members YAML Format
```yaml
- member_id: walshie86
name: Walshie
callsign: Walshie86
role: member
description: IPNet Member
- member_id: craig
name: Craig
callsign: M7XCN
role: member
description: IPNet Member
```
| Field | Required | Description |
| ------------- | -------- | ---------------------------------------- |
| `member_id` | Yes | Unique identifier for the member |
| `name` | Yes | Member's display name |
| `callsign` | No | Amateur radio callsign |
| `role` | No | Member's role in the network |
| `description` | No | Additional description |
| `contact` | No | Contact information |
| `public_key` | No | Associated node public key (64-char hex) |
+54
View File
@@ -2,6 +2,60 @@
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
## v0.11.0
This release removes the `Member` model/table entirely, replacing it with `UserProfile`-backed data. The members system is now driven by OIDC user profiles with roles instead of manually seeded entries.
### Overview of Changes
| Area | Before | After |
|------|--------|-------|
| Network Members | `members` table + CRUD API + YAML seed | Removed — replaced by `UserProfile` roles |
| Member model | `Member` SQLAlchemy model | Deleted (table dropped) |
| Member API | `GET/POST/PUT/DELETE /api/v1/members` | Removed — replaced by `GET /api/v1/user/profiles` |
| Member seeding | `members.yaml` + `import-members` CLI | Removed — profiles auto-created from OIDC |
| Node tags | `member_id` tag key | Replaced by `adopted_by` filter via `user_profile_nodes` |
| Nodes API | `?member_id=` query param | `?adopted_by=` query param (profile UUID) |
| Ads API | `?member_id=` query param | `?adopted_by=` query param (profile UUID) |
| Prometheus | `meshcore_members_total` gauge | `meshcore_user_profiles_total` + `meshcore_user_profiles_by_role` |
| Profile endpoint | `GET /api/v1/user/profile/{user_id}` | `GET /api/v1/user/profile/{profile_id}` (UUID) |
| Profile endpoint | Owner-only GET | Public GET (owner sees `user_id`, public view omits it) |
| New endpoint | — | `GET /api/v1/user/profiles` (list all, paginated, no `user_id`) |
| User profiles | `roles` column missing | `roles` TEXT column added (comma-separated, parsed to list) |
| Admin members UI | `/admin/members` admin page | Removed — Members page now reads from UserProfile |
| Profile page | Owner-only editable | `/profile/:id` public view + `/profile` owner edit |
| Truncate CLI | `--members` flag | Removed |
| `collector seed` | Imports members.yaml | Removed — only imports node_tags.yaml |
| Seed files | `members.yaml` required | `members.yaml` removed |
### Migration Steps
1. **Run database migration** — adds `roles` column to `user_profiles`, drops `members` table
2. **Remove `members.yaml`** from your seed directory (no longer used)
3. **Remove `member_id` tag keys** from `node_tags.yaml` (no longer functional — use node adoption instead)
4. **Update Prometheus alerting rules** that reference `meshcore_members_total` to use `meshcore_user_profiles_total`
### Removed Files
- `seed/members.yaml`
- `example/seed/members.yaml`
### Removed CLI Commands
- `meshcore-hub collector import-members`
- `--members` flag on `meshcore-hub collector truncate`
### Behavior Changes
- The Members page (`/members`) now displays OIDC user profiles grouped by role instead of seeded member data
- The `member_id` node tag is no longer used for filtering or display — use node adoption instead
- Profile endpoints now use UUIDs instead of OIDC `user_id` strings in URLs
- Public profile view (`/profile/{uuid}`) is accessible without authentication
### Database Migration
The migration adds a `roles` column (TEXT, nullable) to `user_profiles` and drops the `members` table.
## v0.10.0
This release includes **breaking changes** to the admin authentication model, OIDC role configuration, and adds user profiles with node adoption.
-14
View File
@@ -1,14 +0,0 @@
# Example members seed file
# Note: Nodes are associated with members via a 'member_id' tag on the node.
# Use node_tags.yaml to set member_id tags on nodes.
members:
- member_id: example_member
name: Example Member
callsign: N0CALL
role: Network Operator
description: Example network operator member
- member_id: simple_member
name: Simple Member
callsign: N0CALL2
role: Observer
description: Example observer member
+34 -7
View File
@@ -14,12 +14,12 @@ from sqlalchemy import func, select
from meshcore_hub.common.models import (
Advertisement,
EventLog,
Member,
Message,
Node,
NodeTag,
Telemetry,
TracePath,
UserProfile,
)
logger = logging.getLogger(__name__)
@@ -270,14 +270,41 @@ def collect_metrics(session: Any) -> bytes:
for event_type, count in event_counts:
events_total.labels(event_type=event_type).set(count)
# -- Members total --
members_total = Gauge(
"meshcore_members_total",
"Total number of network members",
# -- User profiles total --
user_profiles_total = Gauge(
"meshcore_user_profiles_total",
"Total number of user profiles",
registry=registry,
)
count = session.execute(select(func.count(Member.id))).scalar() or 0
members_total.set(count)
count = session.execute(select(func.count(UserProfile.id))).scalar() or 0
user_profiles_total.set(count)
# -- User profiles by role --
user_profiles_by_role = Gauge(
"meshcore_user_profiles_by_role",
"Number of user profiles by role",
["role"],
registry=registry,
)
role_rows = session.execute(
select(UserProfile.roles, func.count(UserProfile.id)).group_by(
UserProfile.roles
)
).all()
for row_roles, _ in role_rows:
if row_roles:
for role in row_roles.split(","):
role = role.strip()
if role:
role_count = (
session.execute(
select(func.count(UserProfile.id)).where(
UserProfile.roles.contains(role)
)
).scalar()
or 0
)
user_profiles_by_role.labels(role=role).set(role_count)
output: bytes = generate_latest(registry)
return output
+48
View File
@@ -0,0 +1,48 @@
"""Shared utility for getting or creating user profiles."""
import logging
from fastapi import Request
from sqlalchemy import select
from meshcore_hub.api.auth import X_USER_NAME_HEADER, X_USER_ROLES_HEADER
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import UserProfile
logger = logging.getLogger(__name__)
def get_or_create_profile(
session: DbSession,
user_id: str,
request: Request,
) -> UserProfile:
"""Get existing profile or create a new one, updating roles from headers.
Looks up by ``user_id``. If not found, creates a new profile with the
name from the ``X-User-Name`` header. On every call the ``roles`` column
is updated from the ``X-User-Roles`` header so IdP role changes are
reflected on the next authenticated request.
Args:
session: SQLAlchemy database session.
user_id: OIDC subject identifier.
request: FastAPI request (for header access).
Returns:
The resolved :class:`UserProfile` instance.
"""
query = select(UserProfile).where(UserProfile.user_id == user_id)
profile = session.execute(query).scalar_one_or_none()
if not profile:
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
profile = UserProfile(user_id=user_id, name=idp_name)
logger.info("Created new user profile for user_id=%s", user_id)
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
profile.roles = roles_header or None
session.add(profile)
session.commit()
session.refresh(profile)
return profile
-2
View File
@@ -9,7 +9,6 @@ from meshcore_hub.api.routes.advertisements import router as advertisements_rout
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
@@ -27,6 +26,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"])
+4 -21
View File
@@ -6,9 +6,10 @@ from fastapi import APIRouter, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireOperatorOrAdmin, X_USER_NAME_HEADER
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.api.profile_utils import get_or_create_profile
from meshcore_hub.common.models import Node, UserProfileNode
from meshcore_hub.common.schemas.user_profiles import AdoptedNodeRead, NodeAdoptRequest
logger = logging.getLogger(__name__)
@@ -16,24 +17,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _get_or_create_profile(
session: DbSession, user_id: str, request: Request
) -> UserProfile:
"""Get existing profile or create a new one with name from IdP."""
query = select(UserProfile).where(UserProfile.user_id == user_id)
profile = session.execute(query).scalar_one_or_none()
if profile:
return profile
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
profile = UserProfile(user_id=user_id, name=idp_name)
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,
@@ -43,7 +26,7 @@ async def adopt_node(
) -> AdoptedNodeRead:
"""Adopt a node. Requires operator or admin role."""
caller_id, _ = caller_info
profile = _get_or_create_profile(session, caller_id, request)
profile = get_or_create_profile(session, caller_id, request)
public_key = adopt_request.public_key.lower()
@@ -10,7 +10,7 @@ from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.api.observer_utils import fetch_observers_for_events
from meshcore_hub.common.models import Advertisement, Node, NodeTag
from meshcore_hub.common.models import Advertisement, Node, NodeTag, UserProfileNode
from meshcore_hub.common.schemas.messages import (
AdvertisementList,
AdvertisementRead,
@@ -50,8 +50,8 @@ async def list_advertisements(
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
),
member_id: Optional[str] = Query(
None, description="Filter by member_id tag value of source node"
adopted_by: Optional[str] = Query(
None, description="Filter by adopting user profile UUID"
),
since: Optional[datetime] = Query(None, description="Start timestamp"),
until: Optional[datetime] = Query(None, description="End timestamp"),
@@ -100,12 +100,11 @@ async def list_advertisements(
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
if member_id:
# Filter advertisements from nodes that have a member_id tag with the specified value
if adopted_by:
query = query.where(
SourceNode.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "member_id", NodeTag.value == member_id
select(UserProfileNode.node_id).where(
UserProfileNode.user_profile_id == adopted_by
)
)
)
-149
View File
@@ -1,149 +0,0 @@
"""Member API routes."""
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from meshcore_hub.api.auth import RequireAdmin, RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Member
from meshcore_hub.common.schemas.members import (
MemberCreate,
MemberList,
MemberRead,
MemberUpdate,
)
router = APIRouter()
@router.get("", response_model=MemberList)
async def list_members(
_: RequireRead,
session: DbSession,
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> MemberList:
"""List all members with pagination."""
# Get total count
count_query = select(func.count()).select_from(Member)
total = session.execute(count_query).scalar() or 0
# Get members ordered by name
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
members = list(session.execute(query).scalars().all())
return MemberList(
items=[MemberRead.model_validate(m) for m in members],
total=total,
limit=limit,
offset=offset,
)
@router.get("/{member_id}", response_model=MemberRead)
async def get_member(
_: RequireRead,
session: DbSession,
member_id: str,
) -> MemberRead:
"""Get a specific member by ID."""
query = select(Member).where(Member.id == member_id)
member = session.execute(query).scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
return MemberRead.model_validate(member)
@router.post("", response_model=MemberRead, status_code=201)
async def create_member(
_: RequireAdmin,
session: DbSession,
member: MemberCreate,
) -> MemberRead:
"""Create a new member."""
# Check if member_id already exists
query = select(Member).where(Member.member_id == member.member_id)
existing = session.execute(query).scalar_one_or_none()
if existing:
raise HTTPException(
status_code=400,
detail=f"Member with member_id '{member.member_id}' already exists",
)
# Create member
new_member = Member(
member_id=member.member_id,
name=member.name,
callsign=member.callsign,
role=member.role,
description=member.description,
contact=member.contact,
)
session.add(new_member)
session.commit()
session.refresh(new_member)
return MemberRead.model_validate(new_member)
@router.put("/{member_id}", response_model=MemberRead)
async def update_member(
_: RequireAdmin,
session: DbSession,
member_id: str,
member: MemberUpdate,
) -> MemberRead:
"""Update a member."""
query = select(Member).where(Member.id == member_id)
existing = session.execute(query).scalar_one_or_none()
if not existing:
raise HTTPException(status_code=404, detail="Member not found")
# Update fields
if member.member_id is not None:
# Check if new member_id is already taken by another member
check_query = select(Member).where(
Member.member_id == member.member_id, Member.id != member_id
)
collision = session.execute(check_query).scalar_one_or_none()
if collision:
raise HTTPException(
status_code=400,
detail=f"Member with member_id '{member.member_id}' already exists",
)
existing.member_id = member.member_id
if member.name is not None:
existing.name = member.name
if member.callsign is not None:
existing.callsign = member.callsign
if member.role is not None:
existing.role = member.role
if member.description is not None:
existing.description = member.description
if member.contact is not None:
existing.contact = member.contact
session.commit()
session.refresh(existing)
return MemberRead.model_validate(existing)
@router.delete("/{member_id}", status_code=204)
async def delete_member(
_: RequireAdmin,
session: DbSession,
member_id: str,
) -> None:
"""Delete a member."""
query = select(Member).where(Member.id == member_id)
member = session.execute(query).scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
session.delete(member)
session.commit()
+6 -5
View File
@@ -41,7 +41,9 @@ async def list_nodes(
None, description="Search in name tag, node name, or public key"
),
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
adopted_by: Optional[str] = Query(
None, description="Filter by adopting user profile UUID"
),
role: Optional[str] = Query(None, description="Filter by role tag value"),
limit: int = Query(50, ge=1, le=500, description="Page size"),
offset: int = Query(0, ge=0, description="Page offset"),
@@ -106,12 +108,11 @@ async def list_nodes(
else:
query = query.where(Node.adv_type == adv_type)
if member_id:
# Filter nodes that have a member_id tag with the specified value
if adopted_by:
query = query.where(
Node.id.in_(
select(NodeTag.node_id).where(
NodeTag.key == "member_id", NodeTag.value == member_id
select(UserProfileNode.node_id).where(
UserProfileNode.user_profile_id == adopted_by
)
)
)
+148 -47
View File
@@ -2,14 +2,20 @@
import logging
from fastapi import APIRouter, HTTPException, Request, status
from sqlalchemy import select
from fastapi import APIRouter, HTTPException, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireUserOwner, X_USER_NAME_HEADER
from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.api.profile_utils import get_or_create_profile
from meshcore_hub.common.models import UserProfile
from meshcore_hub.common.models.user_profile_node import UserProfileNode
from meshcore_hub.common.schemas.user_profiles import (
AdoptedNodeRead,
UserProfileList,
UserProfileListItem,
UserProfilePublicWithNodes,
UserProfileRead,
UserProfileUpdate,
UserProfileWithNodes,
@@ -20,44 +26,8 @@ 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, request: Request
) -> UserProfile:
"""Get existing profile or create a new one with name from IdP."""
query = select(UserProfile).where(UserProfile.user_id == user_id)
profile = session.execute(query).scalar_one_or_none()
if profile:
return profile
idp_name = request.headers.get(X_USER_NAME_HEADER) or None
profile = UserProfile(user_id=user_id, name=idp_name)
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,
request: Request,
) -> 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, request)
def _build_adopted_nodes(profile: UserProfile) -> list[AdoptedNodeRead]:
"""Extract adopted node list from a profile's eager-loaded associations."""
adopted_nodes = []
for assoc in profile.node_associations:
adopted_nodes.append(
@@ -68,29 +38,160 @@ async def get_profile(
adopted_at=assoc.adopted_at,
)
)
return adopted_nodes
@router.get("/profiles", response_model=UserProfileList)
async def list_profiles(
_: RequireRead,
session: DbSession,
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> UserProfileList:
"""List all user profiles with node counts. No user_id exposed."""
count_query = select(func.count(UserProfile.id))
total = session.execute(count_query).scalar() or 0
query = (
select(UserProfile)
.options(
selectinload(UserProfile.node_associations).selectinload(
UserProfileNode.node
)
)
.order_by(UserProfile.name)
.offset(offset)
.limit(limit)
)
profiles = session.execute(query).scalars().all()
items = []
for profile in profiles:
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,
)
)
items.append(
UserProfileListItem(
id=profile.id,
name=profile.name,
callsign=profile.callsign,
roles=profile.role_list,
node_count=len(profile.node_associations),
adopted_nodes=adopted_nodes,
)
)
return UserProfileList(items=items, total=total, limit=limit, offset=offset)
@router.get("/profile/me", response_model=UserProfileWithNodes)
async def get_my_profile(
request: Request,
session: DbSession,
) -> UserProfileWithNodes:
"""Get the current user's profile (via X-User-Id header).
Auto-creates the profile if it doesn't exist. Returns the full
profile including user_id and adopted nodes.
"""
oidc_user_id = request.headers.get(X_USER_ID_HEADER)
if not oidc_user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User identity required",
)
profile = get_or_create_profile(session, oidc_user_id, request)
return UserProfileWithNodes(
id=profile.id,
user_id=profile.user_id,
name=profile.name,
callsign=profile.callsign,
roles=profile.role_list,
created_at=profile.created_at,
updated_at=profile.updated_at,
nodes=adopted_nodes,
nodes=_build_adopted_nodes(profile),
)
@router.put("/profile/{user_id}", response_model=UserProfileRead)
@router.get("/profile/{profile_id}")
async def get_profile(
profile_id: str,
request: Request,
session: DbSession,
) -> UserProfilePublicWithNodes | UserProfileWithNodes:
"""Get a user profile by UUID.
Public access is allowed for viewing any profile. If the caller is the
owner (authenticated with matching user_id), the full profile including
user_id is returned and the profile is auto-created if missing.
"""
oidc_user_id = request.headers.get(X_USER_ID_HEADER)
if oidc_user_id:
caller_query = select(UserProfile).where(UserProfile.user_id == oidc_user_id)
caller_profile = session.execute(caller_query).scalar_one_or_none()
if caller_profile and str(caller_profile.id) == str(profile_id):
profile = get_or_create_profile(session, oidc_user_id, request)
return UserProfileWithNodes(
id=profile.id,
user_id=profile.user_id,
name=profile.name,
callsign=profile.callsign,
roles=profile.role_list,
created_at=profile.created_at,
updated_at=profile.updated_at,
nodes=_build_adopted_nodes(profile),
)
public_query = select(UserProfile).where(UserProfile.id == profile_id)
public_profile = session.execute(public_query).scalar_one_or_none()
if not public_profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Profile not found",
)
return UserProfilePublicWithNodes(
id=public_profile.id,
name=public_profile.name,
callsign=public_profile.callsign,
roles=public_profile.role_list,
created_at=public_profile.created_at,
updated_at=public_profile.updated_at,
nodes=_build_adopted_nodes(public_profile),
)
@router.put("/profile/{profile_id}", response_model=UserProfileRead)
async def update_profile(
user_id: str,
profile_id: str,
profile_update: UserProfileUpdate,
caller_id: RequireUserOwner,
session: DbSession,
request: Request,
) -> UserProfileRead:
"""Update a user profile."""
_verify_owner(caller_id, user_id)
profile = _get_or_create_profile(session, user_id, request)
"""Update a user profile. Only the owner or admin can update."""
profile_query = select(UserProfile).where(UserProfile.id == profile_id)
profile = session.execute(profile_query).scalar_one_or_none()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Profile not found",
)
if profile.user_id != caller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: cannot modify another user's profile",
)
if profile_update.name is not None:
profile.name = profile_update.name
+14 -137
View File
@@ -325,7 +325,6 @@ def seed_cmd(
Looks for the following files in SEED_HOME:
- node_tags.yaml: Node tag definitions (keyed by public_key)
- members.yaml: Network member definitions
Files that don't exist are skipped. This command is idempotent -
existing records are updated, new records are created.
@@ -379,7 +378,6 @@ def _run_seed_import(
"""
from pathlib import Path
from meshcore_hub.collector.member_import import import_members
from meshcore_hub.collector.tag_import import import_tags
imported_any = False
@@ -410,26 +408,6 @@ def _run_seed_import(
elif verbose:
click.echo(f"\nNo node_tags.yaml found in {seed_home}")
# Import members if file exists
members_file = Path(seed_home) / "members.yaml"
if members_file.exists():
if verbose:
click.echo(f"\nImporting members from: {members_file}")
stats = import_members(
file_path=str(members_file),
db=db,
)
if verbose:
click.echo(
f" Members: {stats['created']} created, {stats['updated']} updated"
)
if stats["errors"]:
for error in stats["errors"]:
click.echo(f" Error: {error}", err=True)
imported_any = True
elif verbose:
click.echo(f"\nNo members.yaml found in {seed_home}")
return imported_any
@@ -536,83 +514,6 @@ def import_tags_cmd(
db.dispose()
@collector.command("import-members")
@click.argument("file", type=click.Path(), required=False, default=None)
@click.pass_context
def import_members_cmd(
ctx: click.Context,
file: str | None,
) -> None:
"""Import network members from a YAML file.
Reads a YAML file containing member definitions and upserts them
into the database. Existing members (matched by name) are updated,
new members are created.
FILE is the path to the YAML file containing members.
If not provided, defaults to {SEED_HOME}/members.yaml.
Expected YAML format (list):
\b
- name: John Doe
callsign: N0CALL
role: Network Operator
description: Example member
Or with "members" key:
\b
members:
- name: John Doe
callsign: N0CALL
"""
from pathlib import Path
configure_logging(level=ctx.obj["log_level"])
# Use members_file from settings if not provided
settings = ctx.obj["settings"]
members_file = file if file else settings.members_file
# Check if file exists
if not Path(members_file).exists():
click.echo(f"Members file not found: {members_file}")
if not file:
click.echo("Specify a file path or create the default members.yaml.")
return
click.echo(f"Importing members from: {members_file}")
click.echo(f"Database: {ctx.obj['database_url']}")
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.collector.member_import import import_members
# Initialize database (schema managed by Alembic migrations)
db = DatabaseManager(ctx.obj["database_url"])
# Import members
stats = import_members(
file_path=members_file,
db=db,
)
# Report results
click.echo("")
click.echo("Import complete:")
click.echo(f" Total members in file: {stats['total']}")
click.echo(f" Members created: {stats['created']}")
click.echo(f" Members updated: {stats['updated']}")
if stats["errors"]:
click.echo("")
click.echo("Errors:")
for error in stats["errors"]:
click.echo(f" - {error}", err=True)
db.dispose()
@collector.command("cleanup")
@click.option(
"--retention-days",
@@ -701,12 +602,6 @@ def cleanup_cmd(
@collector.command("truncate")
@click.option(
"--members",
is_flag=True,
default=False,
help="Truncate members table",
)
@click.option(
"--nodes",
is_flag=True,
@@ -759,7 +654,6 @@ def cleanup_cmd(
@click.pass_context
def truncate_cmd(
ctx: click.Context,
members: bool,
nodes: bool,
messages: bool,
advertisements: bool,
@@ -774,9 +668,6 @@ def truncate_cmd(
WARNING: This permanently deletes data! Use with caution.
Examples:
# Clear members table
meshcore-hub collector truncate --members
# Clear messages and advertisements
meshcore-hub collector truncate --messages --advertisements
@@ -791,7 +682,6 @@ def truncate_cmd(
# Determine what to truncate
if truncate_all:
tables_to_clear = {
"members": True,
"nodes": True,
"messages": True,
"advertisements": True,
@@ -801,7 +691,6 @@ def truncate_cmd(
}
else:
tables_to_clear = {
"members": members,
"nodes": nodes,
"messages": messages,
"advertisements": advertisements,
@@ -848,7 +737,6 @@ def truncate_cmd(
from meshcore_hub.common.models import (
Advertisement,
EventLog,
Member,
Message,
Node,
NodeTag,
@@ -856,49 +744,38 @@ def truncate_cmd(
TracePath,
)
from sqlalchemy import delete
from sqlalchemy.engine import CursorResult
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
# Truncate in correct order to respect foreign keys
cleared: list[str] = []
# Clear members (no dependencies)
if tables_to_clear.get("members"):
result: CursorResult = session.execute(delete(Member)) # type: ignore
cleared.append(f"members: {result.rowcount} rows")
# Clear event-specific tables first (they depend on nodes)
if tables_to_clear.get("messages"):
result = session.execute(delete(Message)) # type: ignore
cleared.append(f"messages: {result.rowcount} rows")
result = session.execute(delete(Message))
cleared.append(f"messages: {result.rowcount} rows") # type: ignore[attr-defined]
if tables_to_clear.get("advertisements"):
result = session.execute(delete(Advertisement)) # type: ignore
cleared.append(f"advertisements: {result.rowcount} rows")
result = session.execute(delete(Advertisement))
cleared.append(f"advertisements: {result.rowcount} rows") # type: ignore[attr-defined]
if tables_to_clear.get("telemetry"):
result = session.execute(delete(Telemetry)) # type: ignore
cleared.append(f"telemetry: {result.rowcount} rows")
result = session.execute(delete(Telemetry))
cleared.append(f"telemetry: {result.rowcount} rows") # type: ignore[attr-defined]
if tables_to_clear.get("trace_paths"):
result = session.execute(delete(TracePath)) # type: ignore
cleared.append(f"trace_paths: {result.rowcount} rows")
result = session.execute(delete(TracePath))
cleared.append(f"trace_paths: {result.rowcount} rows") # type: ignore[attr-defined]
if tables_to_clear.get("event_logs"):
result = session.execute(delete(EventLog)) # type: ignore
cleared.append(f"event_logs: {result.rowcount} rows")
result = session.execute(delete(EventLog))
cleared.append(f"event_logs: {result.rowcount} rows") # type: ignore[attr-defined]
# Clear nodes last (this will cascade delete tags and any remaining events)
if tables_to_clear.get("nodes"):
# Delete tags first (they depend on nodes)
tag_result: CursorResult = session.execute(delete(NodeTag)) # type: ignore
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)")
tag_result = session.execute(delete(NodeTag))
cleared.append(f"node_tags: {tag_result.rowcount} rows (cascade)") # type: ignore[attr-defined]
# Delete nodes (will cascade to remaining related tables)
node_result: CursorResult = session.execute(delete(Node)) # type: ignore
cleared.append(f"nodes: {node_result.rowcount} rows")
node_result = session.execute(delete(Node))
cleared.append(f"nodes: {node_result.rowcount} rows") # type: ignore[attr-defined]
db.dispose()
-181
View File
@@ -1,181 +0,0 @@
"""Import members from YAML file."""
import logging
from pathlib import Path
from typing import Any, Optional
import yaml
from pydantic import BaseModel, Field
from sqlalchemy import select
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import Member
logger = logging.getLogger(__name__)
class MemberData(BaseModel):
"""Schema for a member entry in the import file.
Note: Nodes are associated with members via a 'member_id' tag on the node,
not through this schema.
"""
member_id: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
callsign: Optional[str] = Field(default=None, max_length=20)
role: Optional[str] = Field(default=None, max_length=100)
description: Optional[str] = Field(default=None)
contact: Optional[str] = Field(default=None, max_length=255)
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
"""Load and validate members from a YAML file.
Supports two formats:
1. List of member objects:
- member_id: member1
name: Member 1
callsign: M1
2. Object with "members" key:
members:
- member_id: member1
name: Member 1
callsign: M1
Args:
file_path: Path to the members YAML file
Returns:
List of validated member dictionaries
Raises:
FileNotFoundError: If file does not exist
yaml.YAMLError: If file is not valid YAML
ValueError: If file content is invalid
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Members file not found: {file_path}")
with open(path, "r") as f:
data = yaml.safe_load(f)
# Handle both formats
if isinstance(data, list):
members_list = data
elif isinstance(data, dict) and "members" in data:
members_list = data["members"]
if not isinstance(members_list, list):
raise ValueError("'members' key must contain a list")
else:
raise ValueError("Members file must be a list or a mapping with 'members' key")
# Validate each member
validated: list[dict[str, Any]] = []
for i, member in enumerate(members_list):
if not isinstance(member, dict):
raise ValueError(f"Member at index {i} must be an object")
if "member_id" not in member:
raise ValueError(f"Member at index {i} must have a 'member_id' field")
if "name" not in member:
raise ValueError(f"Member at index {i} must have a 'name' field")
# Validate using Pydantic model
try:
validated_member = MemberData.model_validate(member)
validated.append(validated_member.model_dump())
except Exception as e:
raise ValueError(f"Invalid member at index {i}: {e}")
return validated
def import_members(
file_path: str | Path,
db: DatabaseManager,
) -> dict[str, Any]:
"""Import members from a YAML file into the database.
Performs upsert operations based on member_id - existing members are updated,
new members are created.
Note: Nodes are associated with members via a 'member_id' tag on the node.
This import does not manage node associations.
Args:
file_path: Path to the members YAML file
db: Database manager instance
Returns:
Dictionary with import statistics:
- total: Total number of members in file
- created: Number of new members created
- updated: Number of existing members updated
- errors: List of error messages
"""
stats: dict[str, Any] = {
"total": 0,
"created": 0,
"updated": 0,
"errors": [],
}
# Load and validate file
try:
members_data = load_members_file(file_path)
except Exception as e:
stats["errors"].append(f"Failed to load members file: {e}")
return stats
stats["total"] = len(members_data)
with db.session_scope() as session:
for member_data in members_data:
try:
member_id = member_data["member_id"]
name = member_data["name"]
# Find existing member by member_id
query = select(Member).where(Member.member_id == member_id)
existing = session.execute(query).scalar_one_or_none()
if existing:
# Update existing member
if member_data.get("name") is not None:
existing.name = member_data["name"]
if member_data.get("callsign") is not None:
existing.callsign = member_data["callsign"]
if member_data.get("role") is not None:
existing.role = member_data["role"]
if member_data.get("description") is not None:
existing.description = member_data["description"]
if member_data.get("contact") is not None:
existing.contact = member_data["contact"]
stats["updated"] += 1
logger.debug(f"Updated member: {member_id} ({name})")
else:
# Create new member
new_member = Member(
member_id=member_id,
name=name,
callsign=member_data.get("callsign"),
role=member_data.get("role"),
description=member_data.get("description"),
contact=member_data.get("contact"),
)
session.add(new_member)
stats["created"] += 1
logger.debug(f"Created member: {member_id} ({name})")
except Exception as e:
error_msg = f"Error processing member '{member_data.get('member_id', 'unknown')}' ({member_data.get('name', 'unknown')}): {e}"
stats["errors"].append(error_msg)
logger.error(error_msg)
return stats
-7
View File
@@ -181,13 +181,6 @@ class CollectorSettings(CommonSettings):
return str(Path(self.effective_seed_home) / "node_tags.yaml")
@property
def members_file(self) -> str:
"""Get the path to members.yaml in seed_home."""
from pathlib import Path
return str(Path(self.effective_seed_home) / "members.yaml")
@property
def collector_channel_keys_list(self) -> list[str]:
"""Parse configured channel keys into a normalized list."""
@@ -8,7 +8,6 @@ from meshcore_hub.common.models.advertisement import Advertisement
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
@@ -23,7 +22,6 @@ __all__ = [
"TracePath",
"Telemetry",
"EventLog",
"Member",
"UserProfile",
"UserProfileNode",
"EventObserver",
-59
View File
@@ -1,59 +0,0 @@
"""Member model for network member information."""
from typing import Optional
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
class Member(Base, UUIDMixin, TimestampMixin):
"""Member model for network member information.
Stores information about network members/operators.
Nodes are associated with members via a 'member_id' tag on the node.
Attributes:
id: UUID primary key
member_id: Unique member identifier (e.g., 'walshie86')
name: Member's display name
callsign: Amateur radio callsign (optional)
role: Member's role in the network (optional)
description: Additional description (optional)
contact: Contact information (optional)
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "members"
member_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
unique=True,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
callsign: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
)
role: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
)
contact: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
)
def __repr__(self) -> str:
return f"<Member(id={self.id}, member_id={self.member_id}, name={self.name}, callsign={self.callsign})>"
+14 -1
View File
@@ -2,7 +2,7 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
@@ -22,6 +22,7 @@ class UserProfile(Base, UUIDMixin, TimestampMixin):
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)
roles: Comma-separated OIDC roles string (updated on each auth)
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
@@ -42,6 +43,11 @@ class UserProfile(Base, UUIDMixin, TimestampMixin):
String(20),
nullable=True,
)
roles: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
default=None,
)
node_associations: Mapped[list["UserProfileNode"]] = relationship(
"UserProfileNode",
@@ -50,5 +56,12 @@ class UserProfile(Base, UUIDMixin, TimestampMixin):
lazy="selectin",
)
@property
def role_list(self) -> list[str]:
"""Parse comma-separated roles string into a list."""
if not self.roles:
return []
return [r.strip() for r in self.roles.split(",") if r.strip()]
def __repr__(self) -> str:
return f"<UserProfile(id={self.id}, user_id={self.user_id}, name={self.name})>"
@@ -25,12 +25,6 @@ from meshcore_hub.common.schemas.messages import (
MessageList,
MessageFilters,
)
from meshcore_hub.common.schemas.members import (
MemberCreate,
MemberUpdate,
MemberRead,
MemberList,
)
from meshcore_hub.common.schemas.network import (
RadioConfig,
)
@@ -58,11 +52,6 @@ __all__ = [
"MessageRead",
"MessageList",
"MessageFilters",
# Members
"MemberCreate",
"MemberUpdate",
"MemberRead",
"MemberList",
# Network
"RadioConfig",
]
-116
View File
@@ -1,116 +0,0 @@
"""Pydantic schemas for member API endpoints."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class MemberCreate(BaseModel):
"""Schema for creating a member.
Note: Nodes are associated with members via a 'member_id' tag on the node,
not through this schema.
"""
member_id: str = Field(
...,
min_length=1,
max_length=100,
description="Unique member identifier (e.g., 'walshie86')",
)
name: str = Field(
...,
min_length=1,
max_length=255,
description="Member's display name",
)
callsign: Optional[str] = Field(
default=None,
max_length=20,
description="Amateur radio callsign",
)
role: Optional[str] = Field(
default=None,
max_length=100,
description="Member's role in the network",
)
description: Optional[str] = Field(
default=None,
description="Additional description",
)
contact: Optional[str] = Field(
default=None,
max_length=255,
description="Contact information",
)
class MemberUpdate(BaseModel):
"""Schema for updating a member.
Note: Nodes are associated with members via a 'member_id' tag on the node,
not through this schema.
"""
member_id: Optional[str] = Field(
default=None,
min_length=1,
max_length=100,
description="Unique member identifier (e.g., 'walshie86')",
)
name: Optional[str] = Field(
default=None,
min_length=1,
max_length=255,
description="Member's display name",
)
callsign: Optional[str] = Field(
default=None,
max_length=20,
description="Amateur radio callsign",
)
role: Optional[str] = Field(
default=None,
max_length=100,
description="Member's role in the network",
)
description: Optional[str] = Field(
default=None,
description="Additional description",
)
contact: Optional[str] = Field(
default=None,
max_length=255,
description="Contact information",
)
class MemberRead(BaseModel):
"""Schema for reading a member.
Note: Nodes are associated with members via a 'member_id' tag on the node.
To find nodes for a member, query nodes with a 'member_id' tag matching this member.
"""
id: str = Field(..., description="Member UUID")
member_id: str = Field(..., description="Unique member identifier")
name: str = Field(..., description="Member's display name")
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
role: Optional[str] = Field(default=None, description="Member's role")
description: Optional[str] = Field(default=None, description="Description")
contact: Optional[str] = Field(default=None, description="Contact information")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
class Config:
from_attributes = True
class MemberList(BaseModel):
"""Schema for paginated member list response."""
items: list[MemberRead] = Field(..., description="List of members")
total: int = Field(..., description="Total number of members")
limit: int = Field(..., description="Page size limit")
offset: int = Field(..., description="Page offset")
@@ -13,12 +13,77 @@ class UserProfileRead(BaseModel):
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")
roles: list[str] = Field(default_factory=list, description="User roles")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
class Config:
from_attributes = True
@classmethod
def model_validate(cls, obj: object, **kwargs: object) -> "UserProfileRead":
if hasattr(obj, "role_list") and not isinstance(obj, dict):
d = {
"id": str(obj.id), # type: ignore[attr-defined]
"user_id": obj.user_id, # type: ignore[attr-defined]
"name": obj.name, # type: ignore[attr-defined]
"callsign": obj.callsign, # type: ignore[attr-defined]
"roles": obj.role_list,
"created_at": obj.created_at, # type: ignore[attr-defined]
"updated_at": obj.updated_at, # type: ignore[attr-defined]
}
return super().model_validate(d)
return super().model_validate(obj)
class UserProfilePublic(BaseModel):
"""Public-facing schema — omits user_id for privacy."""
id: str = Field(..., description="Profile UUID")
name: Optional[str] = Field(default=None, description="User's display name")
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
roles: list[str] = Field(default_factory=list, description="User roles")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
class Config:
from_attributes = True
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 UserProfileListItem(BaseModel):
"""Schema for a single profile in list responses."""
id: str = Field(..., description="Profile UUID")
name: Optional[str] = Field(default=None, description="User's display name")
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
roles: list[str] = Field(default_factory=list, description="User roles")
node_count: int = Field(default=0, description="Number of adopted nodes")
adopted_nodes: list[AdoptedNodeRead] = Field(
default_factory=list,
description="Nodes adopted by this user",
)
class UserProfileList(BaseModel):
"""Schema for paginated profile list response."""
items: list[UserProfileListItem] = Field(..., description="List of profiles")
total: int = Field(..., description="Total number of profiles")
limit: int = Field(..., description="Page size limit")
offset: int = Field(..., description="Page offset")
class UserProfileUpdate(BaseModel):
"""Schema for updating a user profile."""
@@ -36,20 +101,17 @@ class UserProfileUpdate(BaseModel):
)
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."""
"""Schema for reading a user profile with adopted nodes (owner view)."""
nodes: list[AdoptedNodeRead] = Field(
default_factory=list,
description="Nodes adopted by this user",
)
class UserProfilePublicWithNodes(UserProfilePublic):
"""Public profile view with adopted nodes."""
nodes: list[AdoptedNodeRead] = Field(
default_factory=list,
+37 -28
View File
@@ -76,11 +76,8 @@ def _build_endpoint_access(
"PUT": admin,
"DELETE": admin,
},
"v1/members": {
"v1/user/profiles": {
"GET": _OPEN,
"POST": admin,
"PUT": admin,
"DELETE": admin,
},
"v1/messages": {
"GET": _OPEN,
@@ -102,7 +99,7 @@ def _build_endpoint_access(
"DELETE": operator_admin,
},
"v1/user/profile": {
"GET": any_authenticated,
"GET": _OPEN,
"PUT": any_authenticated,
},
}
@@ -648,28 +645,23 @@ def create_app(
if not request.app.state.features.get("map", True):
return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
nodes_with_location: list[dict[str, Any]] = []
members_list: list[dict[str, Any]] = []
members_by_id: dict[str, dict[str, Any]] = {}
profiles_by_id: dict[str, dict[str, Any]] = {}
error: str | None = None
total_nodes = 0
nodes_with_coords = 0
try:
# Fetch all members to build lookup by member_id
members_response = await request.app.state.http_client.get(
"/api/v1/members", params={"limit": 500}
profiles_response = await request.app.state.http_client.get(
"/api/v1/user/profiles", params={"limit": 500}
)
if members_response.status_code == 200:
members_data = members_response.json()
for member in members_data.get("items", []):
member_info = {
"member_id": member.get("member_id"),
"name": member.get("name"),
"callsign": member.get("callsign"),
if profiles_response.status_code == 200:
profiles_data = profiles_response.json()
for profile in profiles_data.get("items", []):
profiles_by_id[profile["id"]] = {
"id": profile.get("id"),
"name": profile.get("name"),
"callsign": profile.get("callsign"),
}
members_list.append(member_info)
if member.get("member_id"):
members_by_id[member["member_id"]] = member_info
# Fetch all nodes from API
response = await request.app.state.http_client.get(
@@ -686,7 +678,6 @@ def create_app(
tag_lon = None
friendly_name = None
role = None
node_member_id = None
for tag in tags:
key = tag.get("key")
@@ -704,8 +695,6 @@ def create_app(
friendly_name = tag.get("value")
elif key == "role":
role = tag.get("value")
elif key == "member_id":
node_member_id = tag.get("value")
lat = tag_lat if tag_lat is not None else node.get("lat")
lon = tag_lon if tag_lon is not None else node.get("lon")
@@ -722,9 +711,16 @@ def create_app(
or node.get("public_key", "")[:12]
)
public_key = node.get("public_key")
owner = (
members_by_id.get(node_member_id) if node_member_id else None
)
adopted_by = node.get("adopted_by")
owner = None
if adopted_by and adopted_by.get("user_id"):
pass
if adopted_by:
owner = {
"name": adopted_by.get("name"),
"callsign": adopted_by.get("callsign"),
}
nodes_with_location.append(
{
@@ -736,7 +732,6 @@ def create_app(
"last_seen": node.get("last_seen"),
"role": role,
"is_infra": role == "infra",
"member_id": node_member_id,
"owner": owner,
}
)
@@ -770,7 +765,7 @@ def create_app(
return JSONResponse(
{
"nodes": nodes_with_location,
"members": members_list,
"profiles": list(profiles_by_id.values()),
"center": {"lat": center_lat, "lon": center_lon},
"infra_center": infra_center,
"debug": {
@@ -956,6 +951,20 @@ def create_app(
request.session["user"] = session_user
request.session["id_token"] = token.get("id_token")
try:
profile_headers: dict[str, str] = {
"X-User-Id": session_user.get("sub", ""),
"X-User-Roles": ",".join(session_user.get("roles", [])),
}
if session_user.get("name"):
profile_headers["X-User-Name"] = session_user["name"]
await request.app.state.http_client.get(
"/api/v1/user/profile/me", headers=profile_headers
)
except Exception:
logger.exception("Failed to ensure user profile exists")
next_url = request.session.pop("next", "/")
from starlette.responses import RedirectResponse
+1 -3
View File
@@ -23,7 +23,6 @@ const pages = {
notFound: () => import('./pages/not-found.js'),
adminIndex: () => import('./pages/admin/index.js'),
adminNodeTags: () => import('./pages/admin/node-tags.js'),
adminMembers: () => import('./pages/admin/members.js'),
profile: () => import('./pages/profile.js'),
};
@@ -93,12 +92,12 @@ 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));
router.addRoute('/profile/:id', pageHandler(pages.profile));
}
// 404 handler
@@ -156,7 +155,6 @@ function updatePageTitle(pathname) {
'/admin': composePageTitle('entities.admin'),
'/admin/': composePageTitle('entities.admin'),
'/admin/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
'/admin/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
};
// Add feature-dependent titles
@@ -101,3 +101,7 @@ export function iconTag(cls = 'h-5 w-5') {
export function iconUsers(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>`;
}
export function iconAntenna(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" /></svg>`;
}
@@ -1,5 +1,5 @@
import { html, litRender, unsafeHTML, getConfig, hasRole, errorAlert, t } from '../../components.js';
import { iconLock, iconUsers, iconTag } from '../../icons.js';
import { iconLock, iconTag } from '../../icons.js';
export async function render(container, params, router) {
try {
@@ -48,15 +48,6 @@ export async function render(container, params, router) {
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/admin/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconUsers('h-6 w-6')}
${t('entities.members')}
</h2>
<p>${t('admin.members_description')}</p>
</div>
</a>
<a href="/admin/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
@@ -1,325 +0,0 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
import {
html, litRender, nothing,
getConfig, hasRole, errorAlert, successAlert, t, escapeHtml,
} from '../../components.js';
import { iconLock } from '../../icons.js';
export async function render(container, params, router) {
try {
const config = getConfig();
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')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('auth.admin_required')}</p>
<a href="/auth/login" class="btn btn-primary mt-6">${t('auth.login')}</a>
</div>`, container);
return;
}
const flashMessage = (params.query && params.query.message) || '';
const flashError = (params.query && params.query.error) || '';
const data = await apiGet('/api/v1/members', { limit: 100 });
const members = data.items || [];
const flashHtml = html`${flashMessage ? successAlert(flashMessage) : nothing}${flashError ? errorAlert(flashError) : nothing}`;
const tableHtml = members.length > 0
? html`
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>${t('admin_members.member_id')}</th>
<th>${t('common.name')}</th>
<th>${t('common.callsign')}</th>
<th>${t('common.contact')}</th>
<th class="w-32">${t('common.actions')}</th>
</tr>
</thead>
<tbody>${members.map(m => html`
<tr data-member-id=${m.id}
data-member-name=${m.name}
data-member-member-id=${m.member_id}
data-member-callsign=${m.callsign || ''}
data-member-description=${m.description || ''}
data-member-contact=${m.contact || ''}>
<td class="font-mono font-semibold">${m.member_id}</td>
<td>${m.name}</td>
<td>
${m.callsign
? html`<span class="badge badge-primary">${m.callsign}</span>`
: html`<span class="text-base-content/40">-</span>`}
</td>
<td class="max-w-xs truncate" title=${m.contact || ''}>${m.contact || '-'}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">${t('common.edit')}</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
</div>
</td>
</tr>`)}</tbody>
</table>
</div>`
: html`
<div class="text-center py-8 text-base-content/60">
<p>${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}</p>
<p class="text-sm mt-2">${t('admin_members.empty_state_hint')}</p>
</div>`;
litRender(html`
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/admin/">${t('entities.admin')}</a></li>
<li>${t('entities.members')}</li>
</ul>
</div>
</div>
</div>
${flashHtml}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">${t('admin_members.network_members', { count: members.length })}</h2>
<button id="btn-add-member" class="btn btn-primary btn-sm">${t('common.add_entity', { entity: t('entities.member') })}</button>
</div>
${tableHtml}
</div>
</div>
<dialog id="addModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">${t('common.add_new_entity', { entity: t('entities.member') })}</h3>
<form id="add-member-form" class="py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" class="input input-bordered"
placeholder="walshie86" required maxlength="50"
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
<label class="label">
<span class="label-text-alt">${t('admin_members.member_id_hint')}</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
</label>
<input type="text" name="name" class="input input-bordered"
placeholder="John Smith" required maxlength="255">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
<input type="text" name="callsign" class="input input-bordered"
placeholder="VK4ABC" maxlength="20">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
<input type="text" name="contact" class="input input-bordered"
placeholder="john@example.com or phone number" maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">${t('common.description')}</span></label>
<textarea name="description" rows="3" class="textarea textarea-bordered"
placeholder="Brief description of member's role and responsibilities..."></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="addCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.member') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="editModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.member') })}</h3>
<form id="edit-member-form" class="py-4">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
required maxlength="50" pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
</label>
<input type="text" name="name" id="edit_name" class="input input-bordered"
required maxlength="255">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
maxlength="20">
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">${t('common.description')}</span></label>
<textarea name="description" id="edit_description" rows="3"
class="textarea textarea-bordered"></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
<div class="py-4">
<p class="py-4" id="delete_confirm_message"></p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`, container);
let activeDeleteId = '';
const ac = new AbortController();
const signal = ac.signal;
const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal });
// Add Member
on(container.querySelector('#btn-add-member'), 'click', () => {
const form = container.querySelector('#add-member-form');
form.reset();
container.querySelector('#addModal').showModal();
});
on(container.querySelector('#addCancel'), 'click', () => {
container.querySelector('#addModal').close();
});
container.querySelector('#add-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const body = {
member_id: form.member_id.value.trim(),
name: form.name.value.trim(),
callsign: form.callsign.value.trim() || null,
description: form.description.value.trim() || null,
contact: form.contact.value.trim() || null,
};
try {
await apiPost('/api/v1/members', body);
container.querySelector('#addModal').close();
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })), true);
} catch (err) {
container.querySelector('#addModal').close();
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
}
}, { signal });
// Edit Member
container.querySelectorAll('.btn-edit').forEach(btn => {
on(btn, 'click', () => {
const row = btn.closest('tr');
container.querySelector('#edit_id').value = row.dataset.memberId;
container.querySelector('#edit_member_id').value = row.dataset.memberMemberId;
container.querySelector('#edit_name').value = row.dataset.memberName;
container.querySelector('#edit_callsign').value = row.dataset.memberCallsign;
container.querySelector('#edit_description').value = row.dataset.memberDescription;
container.querySelector('#edit_contact').value = row.dataset.memberContact;
container.querySelector('#editModal').showModal();
});
});
on(container.querySelector('#editCancel'), 'click', () => {
container.querySelector('#editModal').close();
});
container.querySelector('#edit-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const id = form.id.value;
const body = {
member_id: form.member_id.value.trim(),
name: form.name.value.trim(),
callsign: form.callsign.value.trim() || null,
description: form.description.value.trim() || null,
contact: form.contact.value.trim() || null,
};
try {
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
container.querySelector('#editModal').close();
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })), true);
} catch (err) {
container.querySelector('#editModal').close();
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
}
}, { signal });
// Delete Member
container.querySelectorAll('.btn-delete').forEach(btn => {
on(btn, 'click', () => {
const row = btn.closest('tr');
activeDeleteId = row.dataset.memberId;
const memberName = row.dataset.memberName;
const confirmMsg = t('common.delete_entity_confirm', {
entity: t('entities.member').toLowerCase(),
name: escapeHtml(memberName)
});
container.querySelector('#delete_confirm_message').innerHTML = confirmMsg;
container.querySelector('#deleteModal').showModal();
});
});
on(container.querySelector('#deleteCancel'), 'click', () => {
container.querySelector('#deleteModal').close();
});
on(container.querySelector('#deleteConfirm'), 'click', async () => {
try {
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
container.querySelector('#deleteModal').close();
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })), true);
} catch (err) {
container.querySelector('#deleteModal').close();
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
}
});
return () => ac.abort();
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}
@@ -2,7 +2,7 @@ import { apiGet } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
truncateKey, warningBadge,
warningBadge,
pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
@@ -12,14 +12,11 @@ export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const public_key = query.public_key || '';
const member_id = query.member_id || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
const config = getConfig();
const features = config.features || {};
const showMembers = features.members !== false;
const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url);
@@ -49,29 +46,21 @@ export async function render(container, params, router) {
${displayContent}`, container);
}
// Render page header immediately (old content stays visible until data loads)
renderPage(nothing);
async function fetchAndRenderData() {
try {
const requests = [
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
const results = await Promise.all([
apiGet('/api/v1/advertisements', { limit, offset, search, public_key }),
apiGet('/api/v1/nodes', { limit: 500 }),
];
if (showMembers) {
requests.push(apiGet('/api/v1/members', { limit: 100 }));
}
const results = await Promise.all(requests);
]);
const data = results[0];
const nodesData = results[1];
const membersData = showMembers ? results[2] : null;
const advertisements = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const allNodes = nodesData.items || [];
const members = membersData?.items || [];
const sortedNodes = allNodes.map(n => {
const tagName = n.tags?.find(t => t.key === 'name')?.value;
@@ -91,19 +80,6 @@ ${displayContent}`, container);
</div>`
: nothing;
const membersFilter = (showMembers && members.length > 0)
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.member')}</span>
</label>
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
</select>
</div>`
: nothing;
const mobileCards = advertisements.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
: advertisements.map(ad => {
@@ -136,7 +112,7 @@ ${displayContent}`, container);
<thead><tr><th>Observer</th><th>${t('common.snr_db')}</th><th>Received</th></tr></thead>
<tbody>
${ad.observers.map(o => {
const dn = o.tag_name || o.name || truncateKey(o.public_key, 12);
const dn = o.tag_name || o.name || o.public_key.slice(0, 12);
const snrD = o.snr != null ? `${Number(o.snr).toFixed(1)}` : '\u2014';
const timeD = formatRelativeTime(o.observed_at);
return html`<tr>
@@ -189,7 +165,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
search, public_key, member_id, limit,
search, public_key, limit,
});
renderPage(html`
@@ -203,7 +179,6 @@ ${displayContent}`, container);
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
${nodesFilter}
${membersFilter}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
@@ -120,7 +120,6 @@ export async function render(container, params, router) {
try {
const data = await apiGet('/map/data');
const allNodes = data.nodes || [];
const allMembers = data.members || [];
const mapCenter = data.center || { lat: 0, lon: 0 };
const infraCenter = data.infra_center || null;
const debug = data.debug || {};
@@ -129,9 +128,9 @@ export async function render(container, params, router) {
const isMobile = window.innerWidth < 768;
const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
const sortedMembers = allMembers.slice().sort((a, b) => a.name.localeCompare(b.name));
function applyFilters() {
const isMobilePortrait
const filteredNodes = applyFiltersCore();
const categoryFilter = container.querySelector('#filter-category').value;
@@ -165,7 +164,6 @@ export async function render(container, params, router) {
function clearFiltersHandler() {
container.querySelector('#filter-category').value = '';
container.querySelector('#filter-type').value = '';
container.querySelector('#filter-member').value = '';
container.querySelector('#show-labels').checked = false;
updateLabelVisibility();
applyFilters();
@@ -204,22 +202,6 @@ export async function render(container, params, router) {
<option value="room">${t('node_types.room')}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.member')}</span>
</label>
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${sortedMembers
.filter(m => m.member_id)
.map(m => {
const label = m.callsign
? m.name + ' (' + m.callsign + ')'
: m.name;
return html`<option value=${m.member_id}>${label}</option>`;
})}
</select>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">${t('map.show_labels')}</span>
@@ -270,13 +252,11 @@ export async function render(container, params, router) {
function applyFiltersCore() {
const categoryFilter = container.querySelector('#filter-category').value;
const typeFilter = container.querySelector('#filter-type').value;
const memberFilter = container.querySelector('#filter-member').value;
const filteredNodes = allNodes.filter(node => {
if (categoryFilter === 'infra' && !node.is_infra) return false;
const nodeType = normalizeType(node.adv_type);
if (typeFilter && nodeType !== typeFilter) return false;
if (memberFilter && node.member_id !== memberFilter) return false;
return true;
});
@@ -1,150 +1,90 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing, t, unsafeHTML,
formatRelativeTime, formatDateTime, errorAlert,
} from '../components.js';
import { iconInfo } from '../icons.js';
import { html, litRender, nothing, t, errorAlert, getConfig } from '../components.js';
import { iconAntenna, iconUsers } from '../icons.js';
function nodeTypeEmoji(advType) {
switch ((advType || '').toLowerCase()) {
case 'chat': return '\u{1F4AC}';
case 'repeater': return '\u{1F4E1}';
case 'room': return '\u{1FAA7}';
default: return advType ? '\u{1F4CD}' : '\u{1F4E6}';
}
}
function nodeSortKey(node) {
const t = (node.adv_type || '').toLowerCase();
if (t === 'repeater') return 0;
if (t === 'chat') return 1;
return 2;
}
function renderNodeCard(node) {
const tagName = node.tags ? (node.tags.find(t => t.key === 'name') || {}).value : null;
const displayName = tagName || node.name;
const emoji = nodeTypeEmoji(node.adv_type);
const relTime = formatRelativeTime(node.last_seen);
const fullTime = formatDateTime(node.last_seen);
const nameBlock = displayName
? html`<div class="font-medium text-sm">${displayName}</div>
<div class="font-mono text-xs opacity-60">${node.public_key.slice(0, 12)}...</div>`
: html`<div class="font-mono text-sm">${node.public_key.slice(0, 12)}...</div>`;
const timeBlock = node.last_seen
? html`<time class="text-xs opacity-60 whitespace-nowrap" datetime=${node.last_seen} title=${fullTime} data-relative-time>${relTime}</time>`
function renderProfileTile(profile) {
const callsignBadge = profile.callsign
? html`<span class="badge badge-neutral badge-sm">${profile.callsign}</span>`
: nothing;
return html`<a href="/nodes/${node.public_key}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
<div class="flex-1 min-w-0">
${nameBlock}
const roleBadges = profile.roles && profile.roles.length > 0
? html`<div class="flex flex-wrap gap-1 mt-1">${profile.roles.map(role =>
html`<span class="badge badge-primary badge-sm">${role}</span>`
)}</div>`
: nothing;
const nodeCountLabel = profile.node_count > 0
? html`<span class="text-sm opacity-60">${t('members_page.node_count', { count: profile.node_count })}</span>`
: nothing;
const nodeBadges = profile.adopted_nodes && profile.adopted_nodes.length > 0
? html`<div class="flex flex-wrap gap-1 mt-2">${profile.adopted_nodes.map(node => {
const label = node.name || node.public_key.slice(0, 12) + '...';
return html`<a href="/nodes/${node.public_key}" class="badge badge-outline badge-sm hover:badge-ghost transition-colors">${label}</a>`;
})}</div>`
: nothing;
return html`<a href="/profile/${profile.id}" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${profile.name || t('common.unnamed')}
${callsignBadge}
</h2>
${roleBadges}
${nodeCountLabel}
${nodeBadges}
</div>
${timeBlock}
</a>`;
}
function renderMemberCard(member, nodes) {
const sorted = [...nodes].sort((a, b) => nodeSortKey(a) - nodeSortKey(b));
const nodesBlock = sorted.length > 0
? html`<div class="mt-4 space-y-2">${sorted.map(renderNodeCard)}</div>`
: nothing;
const callsignBadge = member.callsign
? html`<span class="badge badge-neutral">${member.callsign}</span>`
: nothing;
const descBlock = member.description
? html`<p class="mt-2">${member.description}</p>`
: nothing;
const contactBlock = member.contact
? html`<p class="text-sm mt-2"><span class="opacity-70">${t('common.contact')}:</span> ${member.contact}</p>`
: nothing;
return html`<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
${member.name}
${callsignBadge}
</h2>
${descBlock}
${contactBlock}
${nodesBlock}
</div>
</div>`;
function renderGroup(title, profiles, icon) {
if (profiles.length === 0) return nothing;
return html`
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
${icon}${title}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${profiles.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(renderProfileTile)}
</div>`;
}
export async function render(container, params, router) {
try {
const membersResp = await apiGet('/api/v1/members', { limit: 100 });
const members = membersResp.items || [];
const config = getConfig();
const roleNames = config.role_names || {};
const operatorRole = roleNames.operator || 'operator';
const memberRole = roleNames.member || 'member';
if (members.length === 0) {
const resp = await apiGet('/api/v1/user/profiles', { limit: 500 });
const profiles = resp.items || [];
if (profiles.length === 0) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: 0, entity: t('entities.members').toLowerCase() })}</span>
</div>
<div class="alert alert-info">
${iconInfo('stroke-current shrink-0 h-6 w-6')}
<div>
<h3 class="font-bold">${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}</h3>
<p class="text-sm">${t('members.empty_state_description')}</p>
</div>
</div>
<div class="mt-6 card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('members.members_file_format')}</h2>
<p class="mb-4">${unsafeHTML(t('members.members_file_description'))}</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>members:
- member_id: johndoe
name: John Doe
callsign: AB1CD
role: Network Admin
description: Manages the main repeater node.
contact: john@example.com
- member_id: janesmith
name: Jane Smith
role: Member
description: Regular user in the downtown area.</code></pre>
<p class="mt-4 text-sm opacity-70">
${unsafeHTML(t('members.members_import_instructions'))}
</p>
</div>
<div class="text-center py-12 opacity-70">
<p class="text-lg">${t('members_page.empty_state')}</p>
<p class="text-sm mt-2">${t('members_page.empty_description')}</p>
</div>`, container);
return;
}
const nodePromises = members.map(m =>
apiGet('/api/v1/nodes', { member_id: m.member_id, limit: 50 })
.then(resp => ({ memberId: m.member_id, nodes: resp.items || [] }))
.catch(() => ({ memberId: m.member_id, nodes: [] }))
);
const nodeResults = await Promise.all(nodePromises);
const nodesByMember = {};
for (const r of nodeResults) {
nodesByMember[r.memberId] = r.nodes;
}
const cards = members.map(m =>
renderMemberCard(m, nodesByMember[m.member_id] || [])
const operators = profiles.filter(p => p.roles && p.roles.includes(operatorRole));
const members = profiles.filter(p =>
p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole)
);
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: members.length, entity: t('entities.members').toLowerCase() })}</span>
<span class="badge badge-lg">${t('common.count_entity', { count: profiles.length, entity: t('entities.members').toLowerCase() })}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
${cards}
</div>`, container);
${renderGroup(t('members_page.operators'), operators, html`<span class="text-primary">${iconAntenna('h-6 w-6')}</span>`)}
${renderGroup(t('members_page.members'), members, html`<span class="text-secondary">${iconUsers('h-6 w-6')}</span>`)}
`, container);
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
@@ -138,6 +138,41 @@ export async function render(container, params, router) {
const flashError = (params.query && params.query.error) || '';
const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
const infoGridHtml = adoptionHtml
? html`<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div>
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
${coordsHtml}
</div>
</div>
</div>
${adoptionHtml}
</div>`
: html`<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div>
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
${coordsHtml}
</div>
</div>
</div>`;
litRender(html`
<div class="breadcrumbs text-sm mb-4">
<ul>
@@ -159,21 +194,7 @@ ${heroHtml}
${flashHtml}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div>
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
${coordsHtml}
</div>
</div>
</div>
${infoGridHtml}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
@@ -190,11 +211,7 @@ ${flashHtml}
${adminTagsHtml}
</div>
</div>
</div>
${adoptionHtml}`, container);
// Initialize map if coordinates exist
</div>`, container);
if (hasCoords && typeof L !== 'undefined') {
const map = L.map('header-map', {
zoomControl: false, dragging: false, scrollWheelZoom: false,
@@ -283,7 +300,7 @@ function renderAdoptionSection(node, config) {
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">
return html`<div class="card bg-base-100 shadow-xl h-full">
<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>
@@ -302,7 +319,7 @@ function renderAdoptionSection(node, config) {
? 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">
return html`<div class="card bg-base-100 shadow-xl h-full">
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<div class="flex items-center justify-between">
@@ -313,7 +330,7 @@ function renderAdoptionSection(node, config) {
</div>`;
}
return html`<div class="card bg-base-100 shadow-xl mt-6">
return html`<div class="card bg-base-100 shadow-xl h-full">
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<p class="text-sm opacity-70">${t('nodes.not_adopted')}</p>
@@ -2,8 +2,8 @@ import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, formatDateTime, formatDateTimeShort,
truncateKey, warningBadge,
pagination, timezoneIndicator,
warningBadge,
pagination,
createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -12,14 +12,11 @@ export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const adv_type = query.adv_type || '';
const member_id = query.member_id || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
const config = getConfig();
const features = config.features || {};
const showMembers = features.members !== false;
const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url);
@@ -49,39 +46,15 @@ export async function render(container, params, router) {
${displayContent}`, container);
}
// Render page header immediately (old content stays visible until data loads)
renderPage(nothing);
async function fetchAndRenderData() {
try {
const requests = [
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
];
if (showMembers) {
requests.push(apiGet('/api/v1/members', { limit: 100 }));
}
const results = await Promise.all(requests);
const data = results[0];
const membersData = showMembers ? results[1] : null;
const data = await apiGet('/api/v1/nodes', { limit, offset, search, adv_type });
const nodes = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const members = membersData?.items || [];
const membersFilter = (showMembers && members.length > 0)
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.member')}</span>
</label>
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
</select>
</div>`
: nothing;
const mobileCards = nodes.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
@@ -90,11 +63,6 @@ ${displayContent}`, container);
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
const displayName = tagName || node.name;
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
const memberBlock = (showMembers && member)
? html`<div class="text-xs opacity-60">${member.name}</div>`
: nothing;
return html`<a href="/nodes/${node.public_key}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
@@ -107,26 +75,19 @@ ${displayContent}`, container);
})}
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">${lastSeen}</div>
${memberBlock}
</div>
</div>
</div>
</a>`;
});
const tableColspan = showMembers ? 4 : 3;
const tableRows = nodes.length === 0
? html`<tr><td colspan="${tableColspan}" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
: nodes.map(node => {
const tagName = node.tags?.find(tag => tag.key === 'name')?.value;
const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value;
const displayName = tagName || node.name;
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null;
const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null;
const memberBlock = member
? html`${member.name}${member.callsign ? html` <span class="opacity-60">(${member.callsign})</span>` : nothing}`
: html`<span class="opacity-50">-</span>`;
return html`<tr class="hover">
<td>
<a href="/nodes/${node.public_key}" class="link link-hover">
@@ -145,12 +106,11 @@ ${displayContent}`, container);
title="Click to copy">${node.public_key}</code>
</td>
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
${showMembers ? html`<td class="text-sm">${memberBlock}</td>` : nothing}
</tr>`;
});
const paginationBlock = pagination(page, totalPages, '/nodes', {
search, adv_type, member_id, limit,
search, adv_type, limit,
});
renderPage(html`
@@ -175,7 +135,6 @@ ${displayContent}`, container);
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
</select>
</div>
${membersFilter}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/nodes" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
@@ -195,7 +154,6 @@ ${displayContent}`, container);
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.last_seen')}</th>
${showMembers ? html`<th>${t('entities.member')}</th>` : nothing}
</tr>
</thead>
<tbody>
@@ -19,9 +19,69 @@ function renderAdoptedNode(node) {
</a>`;
}
function renderRoleBadges(roles) {
if (!roles || roles.length === 0) return nothing;
return html`<div class="flex gap-2 mt-2">${roles.map(role => html`<span class="badge badge-primary badge-sm">${role}</span>`)}</div>`;
}
function hasOperatorOrAdmin(roles, config) {
const roleNames = config.role_names || {};
const operatorRole = roleNames.operator || 'operator';
const adminRole = roleNames.admin || 'admin';
return roles && (roles.includes(operatorRole) || roles.includes(adminRole));
}
function renderProfileDetails(profile, config) {
const memberSince = profile.created_at
? html`<p class="text-sm opacity-60 mt-2">${t('user_profile.member_since', { date: formatDateTime(profile.created_at, { year: 'numeric', month: 'long', day: 'numeric' }) })}</p>`
: nothing;
const adoptedSection = hasOperatorOrAdmin(profile.roles, config)
? html`<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">${t('user_profile.adopted_nodes')}</h2>
${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>`}
</div>
</div>`
: nothing;
return html`${memberSince}${adoptedSection}`;
}
function renderPublicProfile(profile, config, target) {
const isOwner = config.user && profile.user_id && config.user.sub === profile.user_id;
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('user_profile.title')}</h1>
${isOwner ? html`<a href="/profile" class="btn btn-primary btn-sm">${t('user_profile.edit_profile')}</a>` : nothing}
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${profile.name || t('common.unnamed')}</h2>
${profile.callsign ? html`<span class="badge badge-neutral">${profile.callsign}</span>` : nothing}
${renderRoleBadges(profile.roles)}
${renderProfileDetails(profile, config)}
</div>
</div>`, target);
}
export async function render(container, params, router) {
const config = getConfig();
if (params.id) {
try {
const profile = await apiGet(`/api/v1/user/profile/${params.id}`);
renderPublicProfile(profile, config, container);
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
return;
}
if (!config.oidc_enabled || !config.user) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
@@ -33,18 +93,13 @@ export async function render(container, params, router) {
}
try {
const userId = config.user.sub;
const profilePath = `/api/v1/user/profile/${encodeURIComponent(userId)}`;
const profile = await apiGet(profilePath);
const profile = await apiGet('/api/v1/user/profile/me');
const profilePath = `/api/v1/user/profile/${profile.id}`;
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>
@@ -54,33 +109,40 @@ ${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 class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('user_profile.your_profile')}</h2>
${renderRoleBadges(profile.roles)}
<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>
${renderProfileDetails(profile, config)}
</div>
${hasOperatorOrAdmin(profile.roles, config) ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('user_profile.adopted_nodes')}</h2>
${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>`}
</div>
</div>
</div>` : nothing}
</div>`, container);
+14 -11
View File
@@ -179,10 +179,8 @@
"select_destination_node": "-- Select destination node --"
},
"members": {
"empty_state_description": "To display network members, create a members.yaml file in your seed directory.",
"members_file_format": "Members File Format",
"members_file_description": "Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:",
"members_import_instructions": "Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>."
"empty_state_description": "No members yet.",
"empty_description": "Members will appear here once users log in and adopt nodes."
},
"not_found": {
"description": "The page you're looking for doesn't exist or has been moved."
@@ -195,7 +193,6 @@
"admin_not_enabled": "The admin interface is not enabled.",
"admin_enable_hint": "Set <code>OIDC_ENABLED=true</code> and configure your OIDC provider to enable admin features.",
"welcome": "Welcome to the admin panel.",
"members_description": "Manage network members and operators.",
"tags_description": "Manage custom tags and metadata for network nodes."
},
"auth": {
@@ -220,13 +217,19 @@
"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"
"login_to_view": "Log in to view your profile",
"view_profile": "View Profile",
"edit_profile": "Edit Profile",
"role_operator": "Operator",
"role_member": "Member",
"member_since": "Member since {{date}}"
},
"admin_members": {
"network_members": "Network Members ({{count}})",
"member_id": "Member ID",
"member_id_hint": "Unique identifier (letters, numbers, underscore)",
"empty_state_hint": "Click \"Add Member\" to create the first member."
"members_page": {
"operators": "Operators",
"members": "Members",
"node_count": "{{count}} nodes",
"empty_state": "No members yet",
"empty_description": "Members will appear here once users log in and adopt nodes."
},
"admin_node_tags": {
"select_node": "Select Node",
-41
View File
@@ -21,7 +21,6 @@ from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import (
Advertisement,
Base,
Member,
Message,
Node,
NodeTag,
@@ -284,23 +283,6 @@ def sample_trace_path(api_db_session):
return trace
@pytest.fixture
def sample_member(api_db_session):
"""Create a sample member in the database."""
member = Member(
member_id="alice",
name="Alice Smith",
callsign="W1ABC",
role="Admin",
description="Network administrator",
contact="alice@example.com",
)
api_db_session.add(member)
api_db_session.commit()
api_db_session.refresh(member)
return member
@pytest.fixture
def receiver_node(api_db_session):
"""Create a receiver node in the database."""
@@ -405,29 +387,6 @@ def sample_node_with_name_tag(api_db_session):
return node
@pytest.fixture
def sample_node_with_member_tag(api_db_session):
"""Create a node with a member_id tag for filter testing."""
node = Node(
public_key="member123member123member123membe",
name="Member Node",
adv_type="CHAT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add(node)
api_db_session.commit()
tag = NodeTag(
node_id=node.id,
key="member_id",
value="alice",
)
api_db_session.add(tag)
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."""
-27
View File
@@ -214,33 +214,6 @@ class TestListAdvertisementsFilters:
data = response.json()
assert len(data["items"]) == 1
def test_filter_by_member_id(
self, client_no_auth, api_db_session, sample_node_with_member_tag
):
"""Test filtering advertisements by member_id tag."""
# Create an advertisement for the node with member tag
advert = Advertisement(
public_key=sample_node_with_member_tag.public_key,
name="Member Node Ad",
adv_type="CHAT",
received_at=datetime.now(timezone.utc),
node_id=sample_node_with_member_tag.id,
)
api_db_session.add(advert)
api_db_session.commit()
# Filter by member_id
response = client_no_auth.get("/api/v1/advertisements?member_id=alice")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
# No match
response = client_no_auth.get("/api/v1/advertisements?member_id=unknown")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
def test_filter_by_since(self, client_no_auth, api_db_session):
"""Test filtering advertisements by since timestamp."""
now = datetime.now(timezone.utc)
-285
View File
@@ -1,285 +0,0 @@
"""Tests for member API routes."""
class TestListMembers:
"""Tests for GET /members endpoint."""
def test_list_members_empty(self, client_no_auth):
"""Test listing members when database is empty."""
response = client_no_auth.get("/api/v1/members")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_members_with_data(self, client_no_auth, sample_member):
"""Test listing members with data in database."""
response = client_no_auth.get("/api/v1/members")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 1
assert data["items"][0]["member_id"] == sample_member.member_id
assert data["items"][0]["name"] == sample_member.name
assert data["items"][0]["callsign"] == sample_member.callsign
def test_list_members_pagination(self, client_no_auth, sample_member):
"""Test member list pagination parameters."""
response = client_no_auth.get("/api/v1/members?limit=25&offset=10")
assert response.status_code == 200
data = response.json()
assert data["limit"] == 25
assert data["offset"] == 10
def test_list_members_requires_read_auth(self, client_with_auth):
"""Test listing members requires read auth when configured."""
# Without auth header
response = client_with_auth.get("/api/v1/members")
assert response.status_code == 401
# With read key
response = client_with_auth.get(
"/api/v1/members",
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 200
class TestGetMember:
"""Tests for GET /members/{member_id} endpoint."""
def test_get_member_success(self, client_no_auth, sample_member):
"""Test getting a specific member."""
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
assert response.status_code == 200
data = response.json()
assert data["member_id"] == sample_member.member_id
assert data["name"] == sample_member.name
assert data["callsign"] == sample_member.callsign
assert data["role"] == sample_member.role
assert data["description"] == sample_member.description
assert data["contact"] == sample_member.contact
def test_get_member_not_found(self, client_no_auth):
"""Test getting a non-existent member."""
response = client_no_auth.get("/api/v1/members/nonexistent-id")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_get_member_requires_read_auth(self, client_with_auth, sample_member):
"""Test getting a member requires read auth when configured."""
# Without auth header
response = client_with_auth.get(f"/api/v1/members/{sample_member.id}")
assert response.status_code == 401
# With read key
response = client_with_auth.get(
f"/api/v1/members/{sample_member.id}",
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 200
class TestCreateMember:
"""Tests for POST /members endpoint."""
def test_create_member_success(self, client_no_auth):
"""Test creating a new member."""
response = client_no_auth.post(
"/api/v1/members",
json={
"member_id": "bob",
"name": "Bob Jones",
"callsign": "W2XYZ",
"role": "Member",
"description": "Regular member",
"contact": "bob@example.com",
},
)
assert response.status_code == 201
data = response.json()
assert data["member_id"] == "bob"
assert data["name"] == "Bob Jones"
assert data["callsign"] == "W2XYZ"
assert data["role"] == "Member"
assert "id" in data
assert "created_at" in data
def test_create_member_minimal(self, client_no_auth):
"""Test creating a member with only required fields."""
response = client_no_auth.post(
"/api/v1/members",
json={
"member_id": "charlie",
"name": "Charlie Brown",
},
)
assert response.status_code == 201
data = response.json()
assert data["member_id"] == "charlie"
assert data["name"] == "Charlie Brown"
assert data["callsign"] is None
assert data["role"] is None
def test_create_member_duplicate_member_id(self, client_no_auth, sample_member):
"""Test creating a member with duplicate member_id fails."""
response = client_no_auth.post(
"/api/v1/members",
json={
"member_id": sample_member.member_id, # "alice" already exists
"name": "Another Alice",
},
)
assert response.status_code == 400
assert "already exists" in response.json()["detail"].lower()
def test_create_member_requires_admin_auth(self, client_with_auth):
"""Test creating a member requires admin auth."""
# Without auth
response = client_with_auth.post(
"/api/v1/members",
json={"member_id": "test", "name": "Test User"},
)
assert response.status_code == 401
# With read key (not admin)
response = client_with_auth.post(
"/api/v1/members",
json={"member_id": "test", "name": "Test User"},
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 403
# With admin key
response = client_with_auth.post(
"/api/v1/members",
json={"member_id": "test", "name": "Test User"},
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 201
class TestUpdateMember:
"""Tests for PUT /members/{member_id} endpoint."""
def test_update_member_success(self, client_no_auth, sample_member):
"""Test updating a member."""
response = client_no_auth.put(
f"/api/v1/members/{sample_member.id}",
json={
"name": "Alice Johnson",
"role": "Super Admin",
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Alice Johnson"
assert data["role"] == "Super Admin"
# Unchanged fields should remain
assert data["member_id"] == sample_member.member_id
assert data["callsign"] == sample_member.callsign
def test_update_member_change_member_id(self, client_no_auth, sample_member):
"""Test updating member_id."""
response = client_no_auth.put(
f"/api/v1/members/{sample_member.id}",
json={"member_id": "alice2"},
)
assert response.status_code == 200
data = response.json()
assert data["member_id"] == "alice2"
def test_update_member_member_id_collision(
self, client_no_auth, api_db_session, sample_member
):
"""Test updating member_id to one that already exists fails."""
from meshcore_hub.common.models import Member
# Create another member
other_member = Member(
member_id="bob",
name="Bob",
)
api_db_session.add(other_member)
api_db_session.commit()
# Try to change alice's member_id to "bob"
response = client_no_auth.put(
f"/api/v1/members/{sample_member.id}",
json={"member_id": "bob"},
)
assert response.status_code == 400
assert "already exists" in response.json()["detail"].lower()
def test_update_member_not_found(self, client_no_auth):
"""Test updating a non-existent member."""
response = client_no_auth.put(
"/api/v1/members/nonexistent-id",
json={"name": "New Name"},
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_update_member_requires_admin_auth(self, client_with_auth, sample_member):
"""Test updating a member requires admin auth."""
# Without auth
response = client_with_auth.put(
f"/api/v1/members/{sample_member.id}",
json={"name": "New Name"},
)
assert response.status_code == 401
# With read key (not admin)
response = client_with_auth.put(
f"/api/v1/members/{sample_member.id}",
json={"name": "New Name"},
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 403
# With admin key
response = client_with_auth.put(
f"/api/v1/members/{sample_member.id}",
json={"name": "New Name"},
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 200
class TestDeleteMember:
"""Tests for DELETE /members/{member_id} endpoint."""
def test_delete_member_success(self, client_no_auth, sample_member):
"""Test deleting a member."""
response = client_no_auth.delete(f"/api/v1/members/{sample_member.id}")
assert response.status_code == 204
# Verify it's deleted
response = client_no_auth.get(f"/api/v1/members/{sample_member.id}")
assert response.status_code == 404
def test_delete_member_not_found(self, client_no_auth):
"""Test deleting a non-existent member."""
response = client_no_auth.delete("/api/v1/members/nonexistent-id")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_delete_member_requires_admin_auth(self, client_with_auth, sample_member):
"""Test deleting a member requires admin auth."""
# Without auth
response = client_with_auth.delete(f"/api/v1/members/{sample_member.id}")
assert response.status_code == 401
# With read key (not admin)
response = client_with_auth.delete(
f"/api/v1/members/{sample_member.id}",
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 403
# With admin key
response = client_with_auth.delete(
f"/api/v1/members/{sample_member.id}",
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 204
+2 -8
View File
@@ -56,7 +56,8 @@ class TestMetricsEndpoint:
assert "meshcore_advertisements_total" in content
assert "meshcore_telemetry_total" in content
assert "meshcore_trace_paths_total" in content
assert "meshcore_members_total" in content
assert "meshcore_user_profiles_total" in content
assert "meshcore_user_profiles_by_role" in content
def test_metrics_info_has_version(self, client_no_auth):
"""Test that meshcore_info includes version label."""
@@ -147,13 +148,6 @@ class TestMetricsData:
assert response.status_code == 200
assert "meshcore_advertisements_total 1.0" in response.text
def test_members_total_reflects_database(self, client_no_auth, sample_member):
"""Test that members_total reflects database state."""
_clear_metrics_cache()
response = client_no_auth.get("/metrics")
assert response.status_code == 200
assert "meshcore_members_total 1.0" in response.text
def test_nodes_by_type_has_labels(self, client_no_auth, sample_node):
"""Test that nodes_by_type includes adv_type labels."""
_clear_metrics_cache()
-14
View File
@@ -168,20 +168,6 @@ class TestListNodesFilters:
assert room_node.public_key in room_keys
assert name_only_room_node.public_key not in room_keys
def test_filter_by_member_id(self, client_no_auth, sample_node_with_member_tag):
"""Test filtering nodes by member_id tag."""
# Match alice
response = client_no_auth.get("/api/v1/nodes?member_id=alice")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
# No match
response = client_no_auth.get("/api/v1/nodes?member_id=unknown")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 0
class TestGetNode:
"""Tests for GET /nodes/{public_key} endpoint."""
+145 -81
View File
@@ -23,66 +23,145 @@ NO_ROLES_HEADERS = {
}
class TestGetProfile:
"""Tests for GET /user/profile/{user_id} endpoint."""
class TestListProfiles:
"""Tests for GET /user/profiles endpoint."""
def test_get_profile_auto_creates(self, client_no_auth):
"""Test getting a non-existent profile auto-creates it."""
def test_list_profiles_returns_list(self, client_no_auth, sample_user_profile):
"""Test listing profiles returns a paginated list."""
response = client_no_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
"/api/v1/user/profiles",
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"] == []
assert "items" in data
assert "total" in data
assert data["total"] >= 1
def test_get_profile_auto_creates_with_name(self, client_no_auth):
"""Test auto-created profile is populated with name from IdP."""
def test_list_profiles_no_user_id(self, client_no_auth, sample_user_profile):
"""Test that list profiles does not expose user_id."""
response = client_no_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
headers=USER_HEADERS_WITH_NAME,
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == TEST_USER_ID
assert data["name"] == "IdP Display Name"
assert data["callsign"] is None
for item in data["items"]:
assert "user_id" not in item
def test_get_profile_does_not_overwrite_existing_name(
def test_list_profiles_includes_roles(self, client_no_auth, sample_user_profile):
"""Test that list profiles includes roles."""
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) >= 1
assert "roles" in data["items"][0]
def test_list_profiles_includes_node_count(
self, client_no_auth, sample_user_profile, sample_adopted_node, sample_node
):
"""Test that list profiles includes node_count and adopted_nodes."""
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
profile = next(p for p in data["items"] if p["id"] == sample_user_profile.id)
assert profile["node_count"] == 1
assert len(profile["adopted_nodes"]) == 1
assert profile["adopted_nodes"][0]["name"] == sample_node.name
class TestGetMyProfile:
"""Tests for GET /user/profile/me endpoint."""
def test_get_my_profile_returns_full_profile(
self, client_no_auth, sample_user_profile
):
"""Test that IdP name does not overwrite an existing profile name."""
"""Test /me returns the full profile with user_id for authenticated user."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.user_id}",
headers=USER_HEADERS_WITH_NAME,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == sample_user_profile.name
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}",
"/api/v1/user/profile/me",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == TEST_USER_ID
assert data["name"] == sample_user_profile.name
def test_get_my_profile_auto_creates(self, client_no_auth):
"""Test /me auto-creates profile for new user."""
new_headers = {
"X-User-Id": "brand-new-user",
"X-User-Roles": "member",
"X-User-Name": "New User",
}
response = client_no_auth.get(
"/api/v1/user/profile/me",
headers=new_headers,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == "brand-new-user"
assert data["name"] == "New User"
def test_get_my_profile_requires_user_id(self, client_no_auth):
"""Test /me returns 401 without X-User-Id header."""
response = client_no_auth.get("/api/v1/user/profile/me")
assert response.status_code == 401
class TestGetProfile:
"""Tests for GET /user/profile/{profile_id} endpoint."""
def test_get_profile_auto_creates_for_owner(self, client_no_auth):
"""Test getting a non-existent profile auto-creates it for the owner."""
response = client_no_auth.get(
"/api/v1/user/profile/nonexistent-uuid",
headers=USER_HEADERS,
)
assert response.status_code == 404
def test_get_existing_profile_by_uuid(self, client_no_auth, sample_user_profile):
"""Test getting an existing profile by UUID."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.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_public_no_auth(self, client_no_auth, sample_user_profile):
"""Test that profile can be viewed without authentication."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
)
assert response.status_code == 200
data = response.json()
assert "user_id" not in data
assert data["name"] == sample_user_profile.name
def test_get_profile_owner_sees_user_id(self, client_no_auth, sample_user_profile):
"""Test that owner sees user_id in their own profile."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data.get("user_id") == sample_user_profile.user_id
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}",
f"/api/v1/user/profile/{sample_user_profile.id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
@@ -91,47 +170,32 @@ class TestGetProfile:
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."""
def test_get_profile_returns_roles(self, client_no_auth, sample_user_profile):
"""Test that profile includes roles."""
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}",
f"/api/v1/user/profile/{sample_user_profile.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
data = response.json()
assert "roles" in data
assert "operator" in data["roles"]
def test_get_profile_not_found(self, client_no_auth):
"""Test 404 for non-existent profile UUID."""
response = client_no_auth.get(
"/api/v1/user/profile/00000000-0000-0000-0000-000000000000",
)
assert response.status_code == 404
class TestUpdateProfile:
"""Tests for PUT /user/profile/{user_id} endpoint."""
"""Tests for PUT /user/profile/{profile_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}",
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"name": "New Name"},
headers=USER_HEADERS,
)
@@ -143,7 +207,7 @@ class TestUpdateProfile:
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}",
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"callsign": "G1NEW"},
headers=USER_HEADERS,
)
@@ -152,32 +216,32 @@ class TestUpdateProfile:
assert data["callsign"] == "G1NEW"
assert data["name"] == sample_user_profile.name
def test_update_profile_auto_creates_with_name(self, client_no_auth):
"""Test updating a non-existent profile auto-creates it with IdP name."""
response = client_no_auth.put(
f"/api/v1/user/profile/{TEST_USER_ID}",
json={"callsign": "W1AUTO"},
headers=USER_HEADERS_WITH_NAME,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == TEST_USER_ID
assert data["name"] == "IdP Display Name"
assert data["callsign"] == "W1AUTO"
def test_update_profile_rejects_wrong_user(self, client_no_auth):
def test_update_profile_rejects_wrong_user(
self, client_no_auth, sample_user_profile
):
"""Test that a user cannot update another user's profile."""
response = client_no_auth.put(
f"/api/v1/user/profile/{TEST_USER_ID}",
f"/api/v1/user/profile/{sample_user_profile.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):
def test_update_profile_rejects_missing_user_id(
self, client_no_auth, sample_user_profile
):
"""Test that missing X-User-Id header is rejected."""
response = client_no_auth.put(
f"/api/v1/user/profile/{TEST_USER_ID}",
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"name": "No Auth"},
)
assert response.status_code == 401
def test_update_profile_not_found(self, client_no_auth):
"""Test 404 for updating non-existent profile."""
response = client_no_auth.put(
"/api/v1/user/profile/00000000-0000-0000-0000-000000000000",
json={"name": "Ghost"},
headers=USER_HEADERS,
)
assert response.status_code == 404
-1
View File
@@ -58,7 +58,6 @@ class TestCollectorSettings:
assert settings.seed_home == "/seed/data"
assert settings.effective_seed_home == "/seed/data"
assert settings.node_tags_file == "/seed/data/node_tags.yaml"
assert settings.members_file == "/seed/data/members.yaml"
def test_collector_channel_keys_list(self) -> None:
"""Channel keys are parsed from comma/space-separated env values."""
-2
View File
@@ -107,11 +107,9 @@ class TestEnJsonCompleteness:
"advertisements",
"messages",
"map",
"members",
"not_found",
"custom_page",
"admin",
"admin_members",
"admin_node_tags",
"footer",
]
-111
View File
@@ -460,114 +460,3 @@ def client_no_features(
"""Create a test client with all features disabled."""
web_app_no_features.state.http_client = mock_http_client
return TestClient(web_app_no_features, raise_server_exceptions=True)
@pytest.fixture
def mock_http_client_with_members() -> MockHttpClient:
"""Create a mock HTTP client with members data."""
client = MockHttpClient()
# Mock the members API response (no nodes in the response anymore)
client.set_response(
"GET",
"/api/v1/members",
200,
{
"items": [
{
"id": "member-1",
"member_id": "alice",
"name": "Alice",
"callsign": "W1ABC",
"role": "Admin",
"description": None,
"contact": "alice@example.com",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
{
"id": "member-2",
"member_id": "bob",
"name": "Bob",
"callsign": "W2XYZ",
"role": "Member",
"description": None,
"contact": None,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
],
"total": 2,
"limit": 100,
"offset": 0,
},
)
# Mock the nodes API response with has_tag filter
# This will be called to get nodes with member_id tags
client.set_response(
"GET",
"/api/v1/nodes",
200,
{
"items": [
{
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"name": "Alice's Node",
"adv_type": "chat",
"flags": None,
"first_seen": "2024-01-01T00:00:00Z",
"last_seen": "2024-01-01T00:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"tags": [
{
"key": "member_id",
"value": "alice",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
{
"key": "name",
"value": "Alice Chat",
"value_type": "string",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
],
}
],
"total": 1,
"limit": 500,
"offset": 0,
},
)
return client
@pytest.fixture
def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any:
"""Create a web app with members API responses configured."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
network_city="Test City",
network_country="Test Country",
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client_with_members
return app
@pytest.fixture
def client_with_members(
web_app_with_members: Any, mock_http_client_with_members: MockHttpClient
) -> TestClient:
"""Create a test client with members API responses configured."""
web_app_with_members.state.http_client = mock_http_client_with_members
return TestClient(web_app_with_members, raise_server_exceptions=True)
+1 -6
View File
@@ -46,11 +46,6 @@ class TestAdvertisementsPageFilters:
response = client.get("/advertisements?search=node")
assert response.status_code == 200
def test_advertisements_with_member_filter(self, client: TestClient) -> None:
"""Test advertisements page with member_id filter returns SPA shell."""
response = client.get("/advertisements?member_id=alice")
assert response.status_code == 200
def test_advertisements_with_public_key_filter(self, client: TestClient) -> None:
"""Test advertisements page with public_key filter returns SPA shell."""
response = client.get(
@@ -71,7 +66,7 @@ class TestAdvertisementsPageFilters:
def test_advertisements_with_all_filters(self, client: TestClient) -> None:
"""Test advertisements page with multiple filters returns SPA shell."""
response = client.get(
"/advertisements?search=test&member_id=alice&page=1&limit=10"
"/advertisements?search=test&public_key=abc123&page=1&limit=10"
)
assert response.status_code == 200
-46
View File
@@ -1,46 +0,0 @@
"""Tests for the members page route (SPA)."""
import json
from fastapi.testclient import TestClient
class TestMembersPage:
"""Tests for the members page."""
def test_members_returns_200(self, client: TestClient) -> None:
"""Test that members page returns 200 status code."""
response = client.get("/members")
assert response.status_code == 200
def test_members_returns_html(self, client: TestClient) -> None:
"""Test that members page returns HTML content."""
response = client.get("/members")
assert "text/html" in response.headers["content-type"]
def test_members_contains_network_name(self, client: TestClient) -> None:
"""Test that members page contains the network name."""
response = client.get("/members")
assert "Test Network" in response.text
def test_members_contains_app_config(self, client: TestClient) -> None:
"""Test that members page contains SPA config."""
response = client.get("/members")
assert "window.__APP_CONFIG__" in response.text
def test_members_contains_spa_script(self, client: TestClient) -> None:
"""Test that members page includes SPA application script."""
response = client.get("/members")
assert "/static/js/spa/app.js" in response.text
def test_members_config_has_network_name(self, client: TestClient) -> None:
"""Test that SPA config includes network name."""
response = client.get("/members")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["network_name"] == "Test Network"
+11 -7
View File
@@ -196,25 +196,29 @@ class TestAPIProxyWriteGating:
def test_post_blocked_for_member(
self, client_with_oidc_member_session: TestClient
) -> None:
"""Test POST blocked for member session."""
"""Test POST to admin endpoint blocked for member session."""
response = client_with_oidc_member_session.post(
"/api/v1/members", json={"name": "test"}
"/api/v1/nodes/some-node/tags",
json={"key": "test", "value": "test"},
)
assert response.status_code == 403
def test_post_allowed_for_admin(
def test_put_allowed_for_admin(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test POST allowed for admin session."""
response = client_with_oidc_admin_session.post(
"/api/v1/members",
"""Test PUT allowed for admin session."""
response = client_with_oidc_admin_session.put(
"/api/v1/user/profile/test-user-id",
json={"name": "test"},
)
assert response.status_code != 403
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"})
response = client.put(
"/api/v1/user/profile/test-user-id",
json={"name": "test"},
)
assert response.status_code == 403
def test_read_open_when_oidc_disabled(self, client: TestClient) -> None: