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
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)
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:
--- /dev/null
+<!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>