mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-06 09:52:00 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user