diff --git a/backend/app/config.py b/backend/app/config.py index ddea219..c234a47 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -110,6 +110,9 @@ class Settings(BaseSettings): site_login_show_signup_link: bool = Field( default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK") ) + site_nav_show_requests: bool = Field( + default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS") + ) site_changelog: Optional[str] = Field(default=CHANGELOG) magent_application_url: Optional[str] = Field( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 232351a..2333808 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -241,6 +241,7 @@ SETTING_KEYS: List[str] = [ "site_login_show_local_login", "site_login_show_forgot_password", "site_login_show_signup_link", + "site_nav_show_requests", ] diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py index 72985ee..71724e9 100644 --- a/backend/app/routers/site.py +++ b/backend/app/routers/site.py @@ -30,6 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]: "showForgotPassword": bool(runtime.site_login_show_forgot_password), "showSignupLink": bool(runtime.site_login_show_signup_link), }, + "navigation": { + "showRequests": bool(runtime.site_nav_show_requests), + }, } if include_changelog: info["changelog"] = (CHANGELOG or "").strip() diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 52bc149..f75c1f0 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -39,6 +39,7 @@ _BOOL_FIELDS = { "site_login_show_local_login", "site_login_show_forgot_password", "site_login_show_signup_link", + "site_nav_show_requests", } _SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"} diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 1ed7b5b..349a58d 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1,4 +1,5 @@ import os +from types import SimpleNamespace import tempfile import unittest from unittest.mock import AsyncMock, patch @@ -13,6 +14,7 @@ from backend.app.network_security import request_trusts_forwarded_headers, valid from backend.app.routers import auth as auth_router from backend.app.routers import portal as portal_router from backend.app.routers import requests as requests_router +from backend.app.routers import site as site_router from backend.app.routers import status as status_router from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.services import password_reset @@ -132,6 +134,26 @@ class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): self.assertIn("credentials", result["message"].lower()) +class SiteInfoTests(unittest.TestCase): + def test_site_public_exposes_requests_navigation_toggle(self) -> None: + runtime = SimpleNamespace( + site_build_number="test-build", + site_banner_enabled=False, + site_banner_message="", + site_banner_tone="info", + site_login_show_jellyfin_login=True, + site_login_show_local_login=True, + site_login_show_forgot_password=True, + site_login_show_signup_link=True, + site_nav_show_requests=False, + ) + + with patch.object(site_router, "get_runtime_settings", return_value=runtime): + info = site_router._build_site_info(False) + + self.assertEqual(info["navigation"], {"showRequests": False}) + + class RequestCacheTests(unittest.TestCase): def tearDown(self) -> None: requests_router._detail_cache.clear() diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 992e222..e190d41 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -50,6 +50,7 @@ const BOOL_SETTINGS = new Set([ 'site_login_show_local_login', 'site_login_show_forgot_password', 'site_login_show_signup_link', + 'site_nav_show_requests', 'magent_proxy_enabled', 'magent_proxy_trust_forwarded_headers', 'magent_ssl_bind_enabled', @@ -272,6 +273,12 @@ const SITE_SECTION_GROUPS: Array<{ 'site_login_show_signup_link', ], }, + { + key: 'site-navigation', + title: 'Beta Navigation', + description: 'Temporarily show or hide beta navigation entries while new request pipelines are built.', + keys: ['site_nav_show_requests'], + }, ] const SETTING_LABEL_OVERRIDES: Record = { @@ -319,6 +326,7 @@ const SETTING_LABEL_OVERRIDES: Record = { site_login_show_local_login: 'Login page: local Magent sign-in', site_login_show_forgot_password: 'Login page: forgot password', site_login_show_signup_link: 'Login page: invite signup link', + site_nav_show_requests: 'Top navigation: Requests', log_file_max_bytes: 'Log file max size (bytes)', log_file_backup_count: 'Rotated log files to keep', log_http_client_level: 'Service HTTP log level', @@ -349,6 +357,7 @@ const labelFromKey = (key: string) => .replace('site banner enabled', 'Sitewide banner enabled') .replace('site banner message', 'Sitewide banner message') .replace('site banner tone', 'Sitewide banner tone') + .replace('site nav show requests', 'Top navigation: Requests') .replace('site changelog', 'Changelog text') const formatBytes = (value?: number | null) => { @@ -645,6 +654,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { 'site_login_show_local_login', 'site_login_show_forgot_password', 'site_login_show_signup_link', + 'site_nav_show_requests', ] const sortByOrder = (items: AdminSetting[], order: string[]) => { const position = new Map(order.map((key, index) => [key, index])) @@ -853,6 +863,8 @@ export default function SettingsPage({ section }: SettingsPageProps) { site_login_show_local_login: 'Show the local Magent login button on the login page.', site_login_show_forgot_password: 'Show the forgot-password link on the login page.', site_login_show_signup_link: 'Show the invite signup link on the login page.', + site_nav_show_requests: + 'Show the Requests item in the top navigation. Disable during beta while request routing is being reworked.', site_changelog: 'One update per line for the public changelog.', } diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx index 4d4fcd6..dacd974 100644 --- a/frontend/app/ui/HeaderActions.tsx +++ b/frontend/app/ui/HeaderActions.tsx @@ -7,18 +7,23 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' export default function HeaderActions() { const [signedIn, setSignedIn] = useState(false) const [role, setRole] = useState(null) + const [showRequestsNav, setShowRequestsNav] = useState(true) const pathname = usePathname() useEffect(() => { const token = getToken() setSignedIn(Boolean(token)) if (!token) { + setShowRequestsNav(true) return } const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/me`) + const [response, siteResponse] = await Promise.all([ + authFetch(`${baseUrl}/auth/me`), + fetch(`${baseUrl}/site/public`).catch(() => null), + ]) if (!response.ok) { clearToken() setSignedIn(false) @@ -27,8 +32,15 @@ export default function HeaderActions() { } const data = await response.json() setRole(data?.role ?? null) + if (siteResponse?.ok) { + const siteData = await siteResponse.json() + setShowRequestsNav(siteData?.navigation?.showRequests !== false) + } else { + setShowRequestsNav(true) + } } catch (err) { console.error(err) + setShowRequestsNav(true) } } void load() @@ -62,18 +74,26 @@ export default function HeaderActions() { }, ] - const items = [ + const commonItems = [ { href: '/', label: 'Health', match: (path: string) => path === '/' }, - { - href: '/portal/requests', - label: 'Requests', - match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'), - }, + ...(showRequestsNav + ? [ + { + href: '/portal/requests', + label: 'Requests', + match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'), + }, + ] + : []), { href: '/portal/issues', label: 'Issues', match: (path: string) => path === '/portal/issues' || path === '/admin/issues', }, + ] + + const items = [ + ...commonItems, ...roleItems, ]