From 2af8b281ead5ae48e02cdf2d48a3896e2d10a904 Mon Sep 17 00:00:00 2001
From: Louis King ${t('admin.members_description')}
${iconUsers('h-6 w-6')}
@@ -57,7 +57,7 @@ export async function render(container, params, router) {
${iconTag('h-6 w-6')}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js
index f65ede3..4b5b1a5 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js
@@ -78,7 +78,7 @@ export async function render(container, params, router) {
@@ -211,15 +211,18 @@ ${flashHtml}
`, 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);
}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
index 79b78ee..290547e 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js
@@ -297,7 +297,7 @@ export async function render(container, params, router) {
@@ -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);
}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
index 8be63d2..abac38f 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
@@ -128,7 +128,7 @@ export async function render(container, params, router) {
const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false)
? html``
: nothing;
diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html
index 6f7cd74..ca6b97e 100644
--- a/src/meshcore_hub/web/templates/spa.html
+++ b/src/meshcore_hub/web/templates/spa.html
@@ -125,9 +125,6 @@
{% if oidc_enabled %}{{ t('entities.admin') }} | {% endif %}{{ t('footer.powered_by') }} MeshCore Hub {{ version }}
+{% if oidc_enabled %}{{ t('entities.admin') }} | {% endif %}{{ t('footer.powered_by') }} MeshCore Hub {{ version }}
diff --git a/tests/test_web/test_admin.py b/tests/test_web/test_admin.py index 8b3f778..36d23cb 100644 --- a/tests/test_web/test_admin.py +++ b/tests/test_web/test_admin.py @@ -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]: diff --git a/tests/test_web/test_oidc.py b/tests/test_web/test_oidc.py index 7f31feb..e90b7a8 100644 --- a/tests/test_web/test_oidc.py +++ b/tests/test_web/test_oidc.py @@ -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: