Add OIDC-gated member filter to Nodes/Advertisements/Map pages, fix profile page issues

- Add member filter dropdown to Nodes, Advertisements, and Map pages
  (visible only when OIDC is enabled), showing profiles as
  "Name (Callsign)" format
- Add adopted_by query param to /map/data endpoint for server-side
  member filtering on the map
- Fix members feature flag: auto-disables when OIDC is disabled
- Fix profile page: remove duplicate adopted nodes section,
  extract renderMemberSince(), align form labels with fixed-width
  label column
- Add i18n keys: common.all_members, common.filter_member_label
- Add map endpoint adopted_by tests, update features tests
This commit is contained in:
Louis King
2026-05-02 14:43:55 +01:00
parent f42cf92664
commit 486178a471
11 changed files with 166 additions and 39 deletions
+1 -1
View File
@@ -419,7 +419,7 @@ Control which pages are visible in the web dashboard. Disabled features are full
| `FEATURE_MEMBERS` | `true` | Enable the `/members` page |
| `FEATURE_PAGES` | `true` | Enable custom markdown pages |
**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled. Members auto-disables when OIDC is disabled (set via `OIDC_ENABLED`).
### Custom Content
+2
View File
@@ -160,6 +160,8 @@ Toast/flash messages after successful operations:
| `view_details` | View Details | View details link |
| `all_types` | All Types | "All types" filter option |
| `all_channels` | All Channels | "All channels" filter option |
| `all_members` | All Members | "All members" filter option (only shown when OIDC is enabled) |
| `filter_member_label` | Member | Label for member filter dropdown |
| `node_type` | Node Type | Node type field |
| `show` | Show | Show/display action |
| `search_placeholder` | Search by name, ID, or public key... | Search input placeholder |
+2 -1
View File
@@ -392,6 +392,7 @@ class WebSettings(CommonSettings):
Automatic dependencies:
- Dashboard requires at least one of nodes/advertisements/messages.
- Map requires nodes (map displays node locations).
- Members requires OIDC to be enabled (honours OIDC_ENABLED).
"""
has_dashboard_content = (
self.feature_nodes or self.feature_advertisements or self.feature_messages
@@ -402,7 +403,7 @@ class WebSettings(CommonSettings):
"advertisements": self.feature_advertisements,
"messages": self.feature_messages,
"map": self.feature_map and self.feature_nodes,
"members": self.feature_members,
"members": self.feature_members and self.oidc_enabled,
"pages": self.feature_pages,
}
+18 -9
View File
@@ -11,7 +11,7 @@ from typing import Any, AsyncGenerator
from zoneinfo import ZoneInfo
import httpx
from fastapi import FastAPI, Request, Response
from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -439,6 +439,7 @@ def create_app(
# Store feature flags with automatic dependencies:
# - Dashboard requires at least one of nodes/advertisements/messages
# - Map requires nodes (map displays node locations)
# - Members requires OIDC to be enabled
effective_features = features if features is not None else settings.features
overrides: dict[str, bool] = {}
has_dashboard_content = (
@@ -450,6 +451,8 @@ def create_app(
overrides["dashboard"] = False
if not effective_features.get("nodes", True):
overrides["map"] = False
if not settings.oidc_enabled:
overrides["members"] = False
if overrides:
effective_features = {**effective_features, **overrides}
app.state.features = effective_features
@@ -640,7 +643,12 @@ def create_app(
# --- Map Data Endpoint (server-side aggregation) ---
@app.get("/map/data", tags=["Map"])
async def map_data(request: Request) -> JSONResponse:
async def map_data(
request: Request,
adopted_by: str | None = Query(
None, description="Filter by adopting user profile UUID"
),
) -> JSONResponse:
"""Return node location data as JSON for the map."""
if not request.app.state.features.get("map", True):
return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
@@ -664,8 +672,11 @@ def create_app(
}
# Fetch all nodes from API
nodes_params: dict[str, str | int] = {"limit": 500}
if adopted_by:
nodes_params["adopted_by"] = adopted_by
response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500}
"/api/v1/nodes", params=nodes_params
)
if response.status_code == 200:
data = response.json()
@@ -712,14 +723,12 @@ def create_app(
)
public_key = node.get("public_key")
adopted_by = node.get("adopted_by")
adopted_info = node.get("adopted_by")
owner = None
if adopted_by and adopted_by.get("user_id"):
pass
if adopted_by:
if adopted_info:
owner = {
"name": adopted_by.get("name"),
"callsign": adopted_by.get("callsign"),
"name": adopted_info.get("name"),
"callsign": adopted_info.get("callsign"),
}
nodes_with_location.append(
@@ -12,6 +12,7 @@ export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const public_key = query.public_key || '';
const adopted_by = query.adopted_by || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
@@ -50,12 +51,19 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
const results = await Promise.all([
apiGet('/api/v1/advertisements', { limit, offset, search, public_key }),
const apiParams = { limit, offset, search, public_key };
if (adopted_by) apiParams.adopted_by = adopted_by;
const fetches = [
apiGet('/api/v1/advertisements', apiParams),
apiGet('/api/v1/nodes', { limit: 500 }),
]);
];
if (config.oidc_enabled) {
fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }));
}
const results = await Promise.all(fetches);
const data = results[0];
const nodesData = results[1];
const profiles = config.oidc_enabled ? (results[2]?.items || []) : [];
const advertisements = data.items || [];
const total = data.total || 0;
@@ -165,7 +173,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
search, public_key, limit,
search, public_key, adopted_by, limit,
});
renderPage(html`
@@ -179,6 +187,24 @@ ${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}
${config.oidc_enabled && profiles.length > 0 ? html`
<div class="form-control max-w-56">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
</label>
<select name="adopted_by" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="" ?selected=${!adopted_by}>${t('common.all_members')}</option>
${profiles.sort((a, b) => {
const na = a.name || a.callsign || '';
const nb = b.name || b.callsign || '';
return na.localeCompare(nb);
}).map(p => html`
<option value=${p.id} ?selected=${adopted_by === p.id}>
${p.callsign ? p.name + ' (' + p.callsign + ')' : (p.name || p.callsign || p.user_id || p.id)}
</option>`)}
</select>
</div>
` : nothing}
<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>
@@ -1,7 +1,7 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing, t,
typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
getConfig, typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
timezoneIndicator,
} from '../components.js';
@@ -118,18 +118,30 @@ function createPopupContent(node) {
export async function render(container, params, router) {
try {
const config = getConfig();
const data = await apiGet('/map/data');
const allNodes = data.nodes || [];
let allNodes = data.nodes || [];
const mapCenter = data.center || { lat: 0, lon: 0 };
const infraCenter = data.infra_center || null;
const debug = data.debug || {};
const profiles = data.profiles || [];
const isMobilePortrait = window.innerWidth < 480;
const isMobile = window.innerWidth < 768;
const BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
function applyFilters() {
let lastMemberFilter = '';
async function applyFilters() {
const memberFilter = document.getElementById('member-filter')?.value || '';
if (memberFilter !== lastMemberFilter) {
lastMemberFilter = memberFilter;
const params = {};
if (memberFilter) params.adopted_by = memberFilter;
const newData = await apiGet('/map/data', params);
allNodes = newData.nodes || [];
}
const filteredNodes = applyFiltersCore();
const categoryFilter = container.querySelector('#filter-category').value;
@@ -164,6 +176,8 @@ export async function render(container, params, router) {
container.querySelector('#filter-category').value = '';
container.querySelector('#filter-type').value = '';
container.querySelector('#show-labels').checked = false;
const memberEl = container.querySelector('#member-filter');
if (memberEl) memberEl.value = '';
updateLabelVisibility();
applyFilters();
}
@@ -201,6 +215,22 @@ export async function render(container, params, router) {
<option value="room">${t('node_types.room')}</option>
</select>
</div>
${config.oidc_enabled && profiles.length > 0 ? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
</label>
<select id="member-filter" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">${t('common.all_members')}</option>
${profiles.sort((a, b) => {
const na = a.name || a.callsign || '';
const nb = b.name || b.callsign || '';
return na.localeCompare(nb);
}).map(p => html`
<option value=${p.id}>${p.callsign ? p.name + ' (' + p.callsign + ')' : (p.name || p.callsign || p.id)}</option>`)}
</select>
</div>
` : nothing}
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">${t('map.show_labels')}</span>
@@ -12,6 +12,7 @@ export async function render(container, params, router) {
const query = params.query || {};
const search = query.search || '';
const adv_type = query.adv_type || '';
const adopted_by = query.adopted_by || '';
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 20;
const offset = (page - 1) * limit;
@@ -50,7 +51,15 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
const data = await apiGet('/api/v1/nodes', { limit, offset, search, adv_type });
const apiParams = { limit, offset, search, adv_type };
if (adopted_by) apiParams.adopted_by = adopted_by;
const fetches = [apiGet('/api/v1/nodes', apiParams)];
if (config.oidc_enabled) {
fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }));
}
const results = await Promise.all(fetches);
const data = results[0];
const profiles = config.oidc_enabled ? (results[1]?.items || []) : [];
const nodes = data.items || [];
const total = data.total || 0;
@@ -110,7 +119,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/nodes', {
search, adv_type, limit,
search, adv_type, adopted_by, limit,
});
renderPage(html`
@@ -135,6 +144,24 @@ ${displayContent}`, container);
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
</select>
</div>
${config.oidc_enabled && profiles.length > 0 ? html`
<div class="form-control max-w-56">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
</label>
<select name="adopted_by" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="" ?selected=${!adopted_by}>${t('common.all_members')}</option>
${profiles.sort((a, b) => {
const na = a.name || a.callsign || '';
const nb = b.name || b.callsign || '';
return na.localeCompare(nb);
}).map(p => html`
<option value=${p.id} ?selected=${adopted_by === p.id}>
${p.callsign ? p.name + ' (' + p.callsign + ')' : (p.name || p.callsign || p.user_id || p.id)}
</option>`)}
</select>
</div>
` : nothing}
<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>
@@ -19,6 +19,12 @@ function renderAdoptedNode(node) {
</a>`;
}
function renderMemberSince(profile) {
return 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;
}
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>`;
@@ -32,10 +38,6 @@ function hasOperatorOrAdmin(roles, config) {
}
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">
@@ -47,7 +49,7 @@ function renderProfileDetails(profile, config) {
</div>`
: nothing;
return html`${memberSince}${adoptedSection}`;
return html`${renderMemberSince(profile)}${adoptedSection}`;
}
function renderPublicProfile(profile, config, target) {
@@ -115,23 +117,23 @@ ${flashHtml}
<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"
<label class="flex items-center gap-3 py-1">
<span class="text-sm font-medium shrink-0 w-24">${t('user_profile.name_label')}</span>
<input type="text" name="name" class="input input-bordered flex-1"
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"
</label>
<label class="flex items-center gap-3 py-1">
<span class="text-sm font-medium shrink-0 w-24">${t('user_profile.callsign_label')}</span>
<input type="text" name="callsign" class="input input-bordered flex-1"
value=${profile.callsign || ''}
placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" />
</div>
</label>
<button type="submit" class="btn btn-primary btn-sm">${t('user_profile.save_profile')}</button>
</form>
${renderMemberSince(profile)}
</div>
</div>
${renderProfileDetails(profile, config)}
</div>
${hasOperatorOrAdmin(profile.roles, config) ? html`
@@ -80,6 +80,8 @@
"view_details": "View Details",
"all_types": "All Types",
"all_channels": "All Channels",
"all_members": "All Members",
"filter_member_label": "Member",
"node_type": "Node Type",
"show": "Show",
"search_placeholder": "Search by name, ID, or public key...",
+6 -5
View File
@@ -13,7 +13,7 @@ class TestFeatureFlagsConfig:
"""Test feature flags in config."""
def test_all_features_enabled_by_default(self, client: TestClient) -> None:
"""All features should be enabled by default in config JSON."""
"""All non-OIDC features should be enabled by default in config JSON."""
response = client.get("/")
assert response.status_code == 200
html = response.text
@@ -22,7 +22,10 @@ class TestFeatureFlagsConfig:
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
assert all(features.values()), "All features should be enabled by default"
non_oidc_features = {k: v for k, v in features.items() if k != "members"}
assert all(
non_oidc_features.values()
), "All non-OIDC features should be enabled by default"
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
"""Features dict should have all 7 expected keys."""
@@ -66,7 +69,6 @@ class TestFeatureFlagsNav:
assert 'href="/advertisements"' in html
assert 'href="/messages"' in html
assert 'href="/map"' in html
assert 'href="/members"' in html
def test_disabled_features_hide_nav_links(
self, client_no_features: TestClient
@@ -128,7 +130,7 @@ class TestFeatureFlagsSEO:
"""Test feature flags affect SEO endpoints."""
def test_sitemap_includes_all_when_enabled(self, client: TestClient) -> None:
"""Sitemap should include all pages when all features are enabled."""
"""Sitemap should include all feature-enabled pages."""
response = client.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
@@ -136,7 +138,6 @@ class TestFeatureFlagsSEO:
assert "/nodes" in content
assert "/advertisements" in content
assert "/map" in content
assert "/members" in content
def test_sitemap_excludes_disabled_features(
self, client_no_features: TestClient
+27
View File
@@ -418,3 +418,30 @@ class TestMapDataInfrastructure:
data = response.json()
assert data["debug"]["infra_nodes"] == 1
class TestMapDataAdoptedByFilter:
"""Tests for map data adopted_by filter parameter."""
def test_map_data_accepts_adopted_by_param(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data endpoint accepts adopted_by query parameter."""
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data?adopted_by=some-profile-uuid")
assert response.status_code == 200
data = response.json()
assert "nodes" in data
assert "profiles" in data
def test_map_data_adopted_by_empty_returns_all(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data without adopted_by returns nodes normally."""
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
assert response.status_code == 200
data = response.json()
# Default mock has 2 nodes, 1 with coordinates
assert len(data["nodes"]) == 1
assert data["nodes"][0]["name"] == "Node Two"