mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-05-18 15:26:00 +02:00
Add OPTIONS to API proxy, fix admin event listener accumulation, rename admin routes from /a/ to /admin/
- Add OPTIONS to the web API proxy route methods for CORS preflight support - Fix event listener accumulation in admin/node-tags.js and admin/members.js using AbortController with cleanup functions returned to the SPA router. lit-html reuses DOM elements across re-renders, causing addEventListener calls to accumulate and fire multiple times per form submission. - Rename admin routes from /a/ prefix to /admin/ for clarity - Add debug logging for admin route access and OIDC role checks - Move auth section in navbar after theme toggle - Update tests and AGENTS.md accordingly
This commit is contained in:
@@ -643,7 +643,7 @@ Key variables:
|
||||
- `OIDC_DISCOVERY_URL` - OIDC discovery URL (required if OIDC_ENABLED=true)
|
||||
- `OIDC_REDIRECT_URI` - Explicit callback URL (overrides auto-derivation)
|
||||
- `OIDC_POST_LOGOUT_REDIRECT_URI` - Post-logout redirect URI (must match IdP sign-out URIs, falls back to `OIDC_REDIRECT_URI` base)
|
||||
- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically for direnv compatibility.
|
||||
- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically for direnv compatibility. When using LogTo as the OIDC provider, include `roles` in `OIDC_SCOPES` (e.g., `"openid email profile roles"`) to enable role-based admin access.
|
||||
- `OIDC_ROLES_CLAIM` - ID token claim for roles (default: `roles`)
|
||||
- `OIDC_ADMIN_ROLE` - Role value for admin access (default: `admin`)
|
||||
- `OIDC_MEMBER_ROLE` - Role value for member access (default: `member`)
|
||||
|
||||
@@ -93,10 +93,10 @@ Role assignment flow:
|
||||
```
|
||||
Browser Web App (:8080) OIDC Provider (IdP)
|
||||
| | |
|
||||
| GET /a/node-tags | |
|
||||
| GET /admin/node-tags | |
|
||||
|------------------------------>| |
|
||||
| | No session |
|
||||
| 302 /auth/login?next=/a/node-tags |
|
||||
| 302 /auth/login?next=/admin/node-tags |
|
||||
|<------------------------------| |
|
||||
| | |
|
||||
| GET /auth/login | |
|
||||
@@ -116,10 +116,10 @@ Browser Web App (:8080) OIDC Provider (IdP
|
||||
| |<------------------------------|
|
||||
| | Extract userinfo + roles |
|
||||
| | Set session cookie |
|
||||
| 302 → /a/node-tags | |
|
||||
| 302 → /admin/node-tags | |
|
||||
|<------------------------------| |
|
||||
| | |
|
||||
| GET /a/node-tags | |
|
||||
| GET /admin/node-tags | |
|
||||
|------------------------------>| |
|
||||
| | Session valid, role=admin |
|
||||
| | Proxy to API with api_key |
|
||||
|
||||
@@ -450,7 +450,7 @@ def create_app(
|
||||
# --- API Proxy ---
|
||||
@app.api_route(
|
||||
"/api/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
tags=["API Proxy"],
|
||||
)
|
||||
async def api_proxy(request: Request, path: str) -> Response:
|
||||
@@ -910,8 +910,8 @@ def create_app(
|
||||
async def spa_catchall(request: Request, path: str = "") -> Response:
|
||||
"""Serve the SPA shell for all non-API routes."""
|
||||
# Admin route protection when OIDC is enabled
|
||||
if path.startswith("a") and (
|
||||
path == "a" or path == "a/" or path.startswith("a/")
|
||||
if path.startswith("admin") and (
|
||||
path == "admin" or path == "admin/" or path.startswith("admin/")
|
||||
):
|
||||
if request.app.state.oidc_enabled:
|
||||
user = get_session_user(request)
|
||||
@@ -919,6 +919,11 @@ def create_app(
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse(url=f"/auth/login?next=/{path}")
|
||||
logger.debug(
|
||||
"Admin route access: path=%s, user=%s",
|
||||
path,
|
||||
user.get("name"),
|
||||
)
|
||||
|
||||
templates_inst: Jinja2Templates = request.app.state.templates
|
||||
features = request.app.state.features
|
||||
|
||||
@@ -54,6 +54,13 @@ def get_user_roles(
|
||||
roles = [roles]
|
||||
is_admin = admin_role in roles
|
||||
is_member = member_role in roles
|
||||
logger.info(
|
||||
"OIDC roles check: roles_claim=%s, raw_roles=%s, is_member=%s, is_admin=%s",
|
||||
roles_claim,
|
||||
roles,
|
||||
is_member,
|
||||
is_admin,
|
||||
)
|
||||
return is_member, is_admin
|
||||
|
||||
|
||||
|
||||
@@ -89,10 +89,10 @@ if (features.pages !== false) {
|
||||
|
||||
// Admin routes (only register when OIDC disabled or user is admin)
|
||||
if (!config.oidc_enabled || config.is_admin) {
|
||||
router.addRoute('/a', pageHandler(pages.adminIndex));
|
||||
router.addRoute('/a/', pageHandler(pages.adminIndex));
|
||||
router.addRoute('/a/node-tags', pageHandler(pages.adminNodeTags));
|
||||
router.addRoute('/a/members', pageHandler(pages.adminMembers));
|
||||
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));
|
||||
}
|
||||
|
||||
// 404 handler
|
||||
@@ -147,10 +147,10 @@ function updatePageTitle(pathname) {
|
||||
const networkName = config.network_name || 'MeshCore Network';
|
||||
const titles = {
|
||||
'/': networkName,
|
||||
'/a': composePageTitle('entities.admin'),
|
||||
'/a/': composePageTitle('entities.admin'),
|
||||
'/a/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
|
||||
'/a/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
|
||||
'/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
|
||||
|
||||
@@ -48,7 +48,7 @@ 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="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<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')}
|
||||
@@ -57,7 +57,7 @@ export async function render(container, params, router) {
|
||||
<p>${t('admin.members_description')}</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<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">
|
||||
${iconTag('h-6 w-6')}
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function render(container, params, router) {
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/a/">${t('entities.admin')}</a></li>
|
||||
<li><a href="/admin/">${t('entities.admin')}</a></li>
|
||||
<li>${t('entities.members')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -211,15 +211,18 @@ ${flashHtml}
|
||||
</dialog>`, container);
|
||||
|
||||
let activeDeleteId = '';
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal });
|
||||
|
||||
// Add Member
|
||||
container.querySelector('#btn-add-member').addEventListener('click', () => {
|
||||
on(container.querySelector('#btn-add-member'), 'click', () => {
|
||||
const form = container.querySelector('#add-member-form');
|
||||
form.reset();
|
||||
container.querySelector('#addModal').showModal();
|
||||
});
|
||||
|
||||
container.querySelector('#addCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#addCancel'), 'click', () => {
|
||||
container.querySelector('#addModal').close();
|
||||
});
|
||||
|
||||
@@ -237,16 +240,16 @@ ${flashHtml}
|
||||
try {
|
||||
await apiPost('/api/v1/members', body);
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })));
|
||||
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#addModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Edit Member
|
||||
container.querySelectorAll('.btn-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
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;
|
||||
@@ -258,7 +261,7 @@ ${flashHtml}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#editCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#editCancel'), 'click', () => {
|
||||
container.querySelector('#editModal').close();
|
||||
});
|
||||
|
||||
@@ -277,16 +280,16 @@ ${flashHtml}
|
||||
try {
|
||||
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })));
|
||||
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Delete Member
|
||||
container.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
on(btn, 'click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeDeleteId = row.dataset.memberId;
|
||||
const memberName = row.dataset.memberName;
|
||||
@@ -299,21 +302,23 @@ ${flashHtml}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#deleteCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#deleteCancel'), 'click', () => {
|
||||
container.querySelector('#deleteModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
|
||||
on(container.querySelector('#deleteConfirm'), 'click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })));
|
||||
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/members?error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
return () => ac.abort();
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ export async function render(container, params, router) {
|
||||
<div class="text-sm breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="/">${t('entities.home')}</a></li>
|
||||
<li><a href="/a/">${t('entities.admin')}</a></li>
|
||||
<li><a href="/admin/">${t('entities.admin')}</a></li>
|
||||
<li>${t('entities.tags')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -328,21 +328,25 @@ ${flashHtml}
|
||||
|
||||
${contentHtml}`, container);
|
||||
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal });
|
||||
|
||||
// Event: node selector change
|
||||
const nodeSelector = container.querySelector('#node-selector');
|
||||
nodeSelector.addEventListener('change', () => {
|
||||
on(nodeSelector, 'change', () => {
|
||||
const pk = nodeSelector.value;
|
||||
if (pk) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(pk));
|
||||
} else {
|
||||
router.navigate('/a/node-tags');
|
||||
router.navigate('/admin/node-tags');
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelector('#load-tags-btn').addEventListener('click', () => {
|
||||
on(container.querySelector('#load-tags-btn'), 'click', () => {
|
||||
const pk = nodeSelector.value;
|
||||
if (pk) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(pk));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -361,15 +365,15 @@ ${contentHtml}`, container);
|
||||
await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', {
|
||||
key, value, value_type,
|
||||
});
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') })));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Edit button handlers
|
||||
container.querySelectorAll('.btn-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
on(btn, 'click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#editKeyDisplay').value = activeTagKey;
|
||||
@@ -379,7 +383,7 @@ ${contentHtml}`, container);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#editCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#editCancel'), 'click', () => {
|
||||
container.querySelector('#editModal').close();
|
||||
});
|
||||
|
||||
@@ -393,16 +397,16 @@ ${contentHtml}`, container);
|
||||
value, value_type,
|
||||
});
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') })));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#editModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Move button handlers
|
||||
container.querySelectorAll('.btn-move').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
on(btn, 'click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
container.querySelector('#moveKeyDisplay').value = activeTagKey;
|
||||
@@ -411,7 +415,7 @@ ${contentHtml}`, container);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#moveCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#moveCancel'), 'click', () => {
|
||||
container.querySelector('#moveModal').close();
|
||||
});
|
||||
|
||||
@@ -425,16 +429,16 @@ ${contentHtml}`, container);
|
||||
new_public_key: newPublicKey,
|
||||
});
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') })));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#moveModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
// Delete button handlers
|
||||
container.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
on(btn, 'click', () => {
|
||||
const row = btn.closest('tr');
|
||||
activeTagKey = row.dataset.tagKey;
|
||||
const confirmMsg = t('common.delete_entity_confirm', {
|
||||
@@ -446,30 +450,30 @@ ${contentHtml}`, container);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#deleteCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#deleteCancel'), 'click', () => {
|
||||
container.querySelector('#deleteModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
|
||||
on(container.querySelector('#deleteConfirm'), 'click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey));
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') })));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Copy All button
|
||||
const copyAllBtn = container.querySelector('#btn-copy-all');
|
||||
if (copyAllBtn) {
|
||||
copyAllBtn.addEventListener('click', () => {
|
||||
on(copyAllBtn, 'click', () => {
|
||||
container.querySelector('#copyAllDestination').selectedIndex = 0;
|
||||
container.querySelector('#copyAllModal').showModal();
|
||||
});
|
||||
|
||||
container.querySelector('#copyAllCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#copyAllCancel'), 'click', () => {
|
||||
container.querySelector('#copyAllModal').close();
|
||||
});
|
||||
|
||||
@@ -482,38 +486,40 @@ ${contentHtml}`, container);
|
||||
const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey));
|
||||
container.querySelector('#copyAllModal').close();
|
||||
const msg = t('admin_node_tags.copied_entities', { copied: result.copied, skipped: result.skipped });
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg));
|
||||
} catch (err) {
|
||||
container.querySelector('#copyAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
// Delete All button
|
||||
const deleteAllBtn = container.querySelector('#btn-delete-all');
|
||||
if (deleteAllBtn) {
|
||||
deleteAllBtn.addEventListener('click', () => {
|
||||
on(deleteAllBtn, 'click', () => {
|
||||
container.querySelector('#deleteAllModal').showModal();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteAllCancel').addEventListener('click', () => {
|
||||
on(container.querySelector('#deleteAllCancel'), 'click', () => {
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
});
|
||||
|
||||
container.querySelector('#deleteAllConfirm').addEventListener('click', async () => {
|
||||
on(container.querySelector('#deleteAllConfirm'), 'click', async () => {
|
||||
try {
|
||||
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags');
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })));
|
||||
} catch (err) {
|
||||
container.querySelector('#deleteAllModal').close();
|
||||
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => ac.abort();
|
||||
|
||||
} catch (e) {
|
||||
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function render(container, params, router) {
|
||||
|
||||
const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false)
|
||||
? html`<div class="mt-3">
|
||||
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
|
||||
<a href="/admin/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
|
||||
@@ -125,9 +125,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end gap-1 pr-2">
|
||||
{% if oidc_enabled %}
|
||||
<div id="auth-section"></div>
|
||||
{% endif %}
|
||||
<span id="nav-loading" class="loading loading-spinner loading-sm hidden"></span>
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
||||
<input type="checkbox" id="theme-toggle" />
|
||||
@@ -136,6 +133,9 @@
|
||||
<!-- moon icon - shown in light mode (click to switch to dark) -->
|
||||
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
|
||||
</label>
|
||||
{% if oidc_enabled %}
|
||||
<div id="auth-section"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/a/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/admin/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -94,13 +94,13 @@ class TestAdminHome:
|
||||
|
||||
def test_admin_home_returns_spa_shell(self, admin_client):
|
||||
"""Test admin home page returns the SPA shell."""
|
||||
response = admin_client.get("/a/")
|
||||
response = admin_client.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_admin_home_config_is_admin(self, admin_client):
|
||||
"""Test admin config shows is_admin: true."""
|
||||
response = admin_client.get("/a/")
|
||||
response = admin_client.get("/admin/")
|
||||
config = _extract_config(response.text)
|
||||
assert config["is_admin"] is True
|
||||
assert config["oidc_enabled"] is True
|
||||
@@ -114,7 +114,7 @@ class TestAdminHome:
|
||||
The SPA catch-all serves the shell for all routes.
|
||||
Client-side code checks oidc_enabled/is_admin to show/hide admin UI.
|
||||
"""
|
||||
response = admin_client_disabled.get("/a/")
|
||||
response = admin_client_disabled.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
@@ -124,14 +124,14 @@ class TestAdminNodeTags:
|
||||
|
||||
def test_node_tags_page_returns_spa_shell(self, admin_client):
|
||||
"""Test node tags page returns the SPA shell."""
|
||||
response = admin_client.get("/a/node-tags")
|
||||
response = admin_client.get("/admin/node-tags")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
def test_node_tags_page_with_public_key(self, admin_client):
|
||||
"""Test node tags page with public_key param returns SPA shell."""
|
||||
response = admin_client.get(
|
||||
"/a/node-tags?public_key=abc123def456abc123def456abc123de",
|
||||
"/admin/node-tags?public_key=abc123def456abc123def456abc123de",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
@@ -141,7 +141,7 @@ class TestAdminNodeTags:
|
||||
admin_client_disabled,
|
||||
):
|
||||
"""Test node tags page returns SPA shell even when admin is disabled."""
|
||||
response = admin_client_disabled.get("/a/node-tags")
|
||||
response = admin_client_disabled.get("/admin/node-tags")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
@@ -153,14 +153,14 @@ class TestAdminFooterLink:
|
||||
"""Test that admin link appears in footer when OIDC is enabled."""
|
||||
response = admin_client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/a/"' in response.text
|
||||
assert 'href="/admin/"' in response.text
|
||||
assert "Admin" in response.text
|
||||
|
||||
def test_admin_link_hidden_when_oidc_disabled(self, admin_client_disabled):
|
||||
"""Test that admin link does not appear in footer when OIDC disabled."""
|
||||
response = admin_client_disabled.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/a/"' not in response.text
|
||||
assert 'href="/admin/"' not in response.text
|
||||
|
||||
|
||||
def _extract_config(text: str) -> dict[str, Any]:
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestAuthLogin:
|
||||
url="https://idp.example.com/authorize?state=abc"
|
||||
)
|
||||
response = client_with_oidc.get(
|
||||
"/auth/login?next=/a/node-tags", follow_redirects=False
|
||||
"/auth/login?next=/admin/node-tags", follow_redirects=False
|
||||
)
|
||||
assert response.status_code == 307
|
||||
assert "idp.example.com" in response.headers["location"]
|
||||
@@ -161,7 +161,7 @@ class TestAdminRouteProtection:
|
||||
|
||||
def test_no_session_redirects_to_login(self, client_with_oidc: TestClient) -> None:
|
||||
"""Test admin route redirects to /auth/login without session."""
|
||||
response = client_with_oidc.get("/a/", follow_redirects=False)
|
||||
response = client_with_oidc.get("/admin/", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert "/auth/login" in response.headers["location"]
|
||||
|
||||
@@ -169,7 +169,7 @@ class TestAdminRouteProtection:
|
||||
self, client_with_oidc_member_session: TestClient
|
||||
) -> None:
|
||||
"""Test member session gets SPA shell (client-side shows access denied)."""
|
||||
response = client_with_oidc_member_session.get("/a/")
|
||||
response = client_with_oidc_member_session.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
config = _extract_config(response.text)
|
||||
@@ -180,7 +180,7 @@ class TestAdminRouteProtection:
|
||||
self, client_with_oidc_admin_session: TestClient
|
||||
) -> None:
|
||||
"""Test admin session gets SPA shell with admin config."""
|
||||
response = client_with_oidc_admin_session.get("/a/")
|
||||
response = client_with_oidc_admin_session.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
config = _extract_config(response.text)
|
||||
assert config["oidc_enabled"] is True
|
||||
@@ -239,7 +239,7 @@ class TestBackwardCompatibility:
|
||||
self, client: TestClient
|
||||
) -> None:
|
||||
"""Test admin routes serve SPA shell when OIDC disabled (no redirect)."""
|
||||
response = client.get("/a/")
|
||||
response = client.get("/admin/")
|
||||
assert response.status_code == 200
|
||||
assert "window.__APP_CONFIG__" in response.text
|
||||
|
||||
@@ -247,7 +247,7 @@ class TestBackwardCompatibility:
|
||||
"""Test footer has no admin link when OIDC disabled."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert 'href="/a/"' not in response.text
|
||||
assert 'href="/admin/"' not in response.text
|
||||
|
||||
|
||||
class TestConfigInjection:
|
||||
|
||||
Reference in New Issue
Block a user