diff --git a/AGENTS.md b/AGENTS.md
index bd7dd22..66b651d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`)
diff --git a/docs/plans/20260428-1300-oidc-oauth-support/plan.md b/docs/plans/20260428-1300-oidc-oauth-support/plan.md
index d5426a4..d11a30a 100644
--- a/docs/plans/20260428-1300-oidc-oauth-support/plan.md
+++ b/docs/plans/20260428-1300-oidc-oauth-support/plan.md
@@ -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 |
diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py
index c29cd71..eea1641 100644
--- a/src/meshcore_hub/web/app.py
+++ b/src/meshcore_hub/web/app.py
@@ -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
diff --git a/src/meshcore_hub/web/oidc.py b/src/meshcore_hub/web/oidc.py
index 4d9c514..44447b2 100644
--- a/src/meshcore_hub/web/oidc.py
+++ b/src/meshcore_hub/web/oidc.py
@@ -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
diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js
index 02dfb12..335d17f 100644
--- a/src/meshcore_hub/web/static/js/spa/app.js
+++ b/src/meshcore_hub/web/static/js/spa/app.js
@@ -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
diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js
index 125ff4c..5150cc4 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js
@@ -48,7 +48,7 @@ export async function render(container, params, router) {
-
+
${iconUsers('h-6 w-6')}
@@ -57,7 +57,7 @@ export async function render(container, params, router) {
${t('admin.members_description')}
-
+
${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 %}
-
- {% endif %}
+ {% if oidc_enabled %}
+
+ {% endif %}
@@ -169,7 +169,7 @@
{{ t('links.youtube') }}
{% endif %}
- {% 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: