Require HA admin user to finish setup on Ingress (#2801)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 12 Dec 2025 21:45:41 +0000 (22:45 +0100)
committerGitHub <noreply@github.com>
Fri, 12 Dec 2025 21:45:41 +0000 (22:45 +0100)
music_assistant/controllers/webserver/auth.py
music_assistant/controllers/webserver/controller.py
music_assistant/controllers/webserver/helpers/auth_providers.py
music_assistant/helpers/resources/error.html [new file with mode: 0644]

index 4c2d948e3974084379d629e6d1d893da6eb0b7f9..cec3e4037a91be35831a93bc1390f6c31cd8b195 100644 (file)
@@ -95,7 +95,7 @@ class AuthenticationManager:
         # Setup login providers based on config
         await self._setup_login_providers(allow_self_registration)
 
-        self._has_users = await self.database.get_count("users") > 0
+        self._has_users = await self._has_non_system_users()
 
         self.logger.info(
             "Authentication manager initialized (providers=%d)", len(self.login_providers)
index f4b491b83a4e55ca1ba91b6779c5e949cc79136e..8881f2738244054251e8747a73e6acead9d0cc21 100644 (file)
@@ -52,7 +52,7 @@ from .helpers.auth_middleware import (
     is_request_from_ingress,
     set_current_user,
 )
-from .helpers.auth_providers import BuiltinLoginProvider
+from .helpers.auth_providers import BuiltinLoginProvider, get_ha_user_role
 from .remote_access import RemoteAccessManager
 from .websocket_client import WebsocketClientHandler
 
@@ -598,10 +598,41 @@ class WebserverController(CoreController):
         swagger_html_path = str(RESOURCES_DIR.joinpath("swagger_ui.html"))
         return await self._server.serve_static(swagger_html_path, request)
 
+    async def _render_error_page(self, error_message: str, status: int = 403) -> web.Response:
+        """Render a user-friendly error page with the given message.
+
+        :param error_message: The error message to display to the user.
+        :param status: HTTP status code for the response.
+        """
+        error_html_path = str(RESOURCES_DIR.joinpath("error.html"))
+        async with aiofiles.open(error_html_path) as f:
+            html_content = await f.read()
+        # Replace placeholder with the actual error message (escape to prevent XSS)
+        html_content = html_content.replace("{{ERROR_MESSAGE}}", html.escape(error_message))
+        return web.Response(text=html_content, content_type="text/html", status=status)
+
     async def _handle_index(self, request: web.Request) -> web.StreamResponse:
         """Handle request for index page (Vue frontend)."""
         # If not yet onboarded, redirect to setup
-        if not self.auth.has_users and not is_request_from_ingress(request):
+        if (
+            not self.auth.has_users
+            and not self.mass.config.onboard_done
+            and is_request_from_ingress(request)
+        ):
+            # a non-admin user tries to access the index via HA ingress
+            # while we're not yet onboarded, prevent that as it leads to a bad UX
+            ingress_user_id = request.headers.get("X-Remote-User-ID", "")
+            role = await get_ha_user_role(self.mass, ingress_user_id)
+            if role != UserRole.ADMIN:
+                return await self._render_error_page(
+                    "Administrator permissions are required to complete the initial setup. "
+                    "Please ask a Home Assistant administrator to complete the setup first."
+                )
+            # NOTE: For ingress admin user,
+            # we allow access to index, user will be auto created and then forwarded to the
+            # frontend (which will take care of onboarding)
+        if not self.auth.has_users:
+            # non ingress request and no users yet, redirect to setup
             return web.Response(status=302, headers={"Location": "setup"})
         # Serve the Vue frontend index.html
         return await self._server.serve_static(self._index_path, request)
@@ -930,11 +961,11 @@ class WebserverController(CoreController):
             if not is_valid:
                 return web.Response(status=400, text="Invalid return_url")
         else:
-            return_url = "/login"
-        # check if setup is already completed
+            return_url = "/"
+
         if self.auth.has_users:
-            # Setup already completed, redirect to login (or provided return_url)
-            return web.Response(status=302, headers={"Location": return_url})
+            # this should not happen, but guard anyways
+            return await self._render_error_page("Setup has already been completed.")
 
         setup_html_path = str(RESOURCES_DIR.joinpath("setup.html"))
         async with aiofiles.open(setup_html_path) as f:
index 51442527006cae694a33ff695767cea23ca6cd51..2600e15b09cc90279c01afec6432d3060d73ac27 100644 (file)
@@ -49,8 +49,7 @@ async def get_ha_user_role(mass: MusicAssistant, ha_user_id: str) -> UserRole:
     try:
         hass_prov = mass.get_provider("hass")
         if hass_prov is None or not hass_prov.available:
-            LOGGER.debug("HA provider not available, returning USER role")
-            return UserRole.USER
+            raise RuntimeError("Home Assistant provider not available")
 
         hass_prov = cast("HomeAssistantProvider", hass_prov)
         # Query HA for user list to check admin status
@@ -64,7 +63,7 @@ async def get_ha_user_role(mass: MusicAssistant, ha_user_id: str) -> UserRole:
                     return UserRole.ADMIN
                 break
     except Exception as err:
-        LOGGER.debug("Failed to check HA admin status: %s", err)
+        LOGGER.error("Failed to check HA admin status: %s", err)
 
     return UserRole.USER
 
diff --git a/music_assistant/helpers/resources/error.html b/music_assistant/helpers/resources/error.html
new file mode 100644 (file)
index 0000000..5aa506d
--- /dev/null
@@ -0,0 +1,149 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Music Assistant - Error</title>
+    <link rel="stylesheet" href="resources/common.css">
+    <style>
+        body {
+            min-height: 100vh;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            padding: 20px;
+        }
+
+        .error-container {
+            background: var(--panel);
+            border-radius: 16px;
+            box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12),
+                        0 0 0 1px var(--border);
+            padding: 48px 40px;
+            width: 100%;
+            max-width: 520px;
+            text-align: center;
+        }
+
+        .logo {
+            text-align: center;
+            margin-bottom: 36px;
+        }
+
+        .logo-icon {
+            width: 72px;
+            height: 72px;
+            margin: 0 auto 16px;
+        }
+
+        .logo-icon img {
+            width: 100%;
+            height: 100%;
+            object-fit: contain;
+        }
+
+        .logo h1 {
+            color: var(--fg);
+            font-size: 24px;
+            font-weight: 600;
+            letter-spacing: -0.5px;
+            margin-bottom: 6px;
+        }
+
+        .error-icon {
+            width: 64px;
+            height: 64px;
+            margin: 0 auto 24px;
+            background: var(--error-bg);
+            border-radius: 50%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+
+        .error-icon svg {
+            width: 32px;
+            height: 32px;
+            color: var(--error-text);
+        }
+
+        .error-title {
+            color: var(--fg);
+            font-size: 20px;
+            font-weight: 600;
+            margin-bottom: 12px;
+        }
+
+        .error-message {
+            color: var(--text-secondary);
+            font-size: 15px;
+            line-height: 1.6;
+            margin-bottom: 32px;
+            display: block;
+            background: transparent;
+            border: none;
+            padding: 0;
+        }
+
+        .btn {
+            display: inline-block;
+            padding: 15px 32px;
+            border: none;
+            border-radius: 10px;
+            font-size: 15px;
+            font-weight: 600;
+            cursor: pointer;
+            transition: all 0.2s ease;
+            letter-spacing: 0.3px;
+            text-decoration: none;
+        }
+
+        .btn-primary {
+            background: var(--primary);
+            color: white;
+        }
+
+        .btn-primary:hover {
+            filter: brightness(1.1);
+            box-shadow: 0 8px 24px var(--primary-glow);
+            transform: translateY(-1px);
+        }
+
+        .btn-primary:active {
+            transform: translateY(0);
+            filter: brightness(0.95);
+        }
+
+        .help-text {
+            margin-top: 24px;
+            color: var(--text-tertiary);
+            font-size: 13px;
+        }
+    </style>
+</head>
+<body>
+    <div class="error-container">
+        <div class="logo">
+            <div class="logo-icon">
+                <img src="logo.png" alt="Music Assistant">
+            </div>
+            <h1>Music Assistant</h1>
+        </div>
+
+        <div class="error-icon">
+            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+            </svg>
+        </div>
+
+        <h2 class="error-title" id="errorTitle">Access Denied</h2>
+        <p class="error-message" id="errorMessage">{{ERROR_MESSAGE}}</p>
+
+        <a href="/" class="btn btn-primary">Go to Home</a>
+
+        <p class="help-text">
+            If you believe this is an error, please contact your administrator.
+        </p>
+    </div>
+</body>
+</html>