self._fernet = Fernet(fernet_key)
config_entries.ENCRYPT_CALLBACK = self.encrypt_string
config_entries.DECRYPT_CALLBACK = self.decrypt_string
+ if not self.onboard_done:
+ self.mass.register_api_command(
+ "config/onboard_complete",
+ self.set_onboard_complete,
+ authenticated=True,
+ alias=True, # hide from public API docs
+ )
LOGGER.debug("Started.")
@property
"""Return True if onboarding is done."""
return bool(self.get(CONF_ONBOARD_DONE, False))
+ async def set_onboard_complete(self) -> None:
+ """
+ Mark onboarding as complete.
+
+ This is called by the frontend after the user has completed the onboarding wizard.
+ Only available when onboarding is not yet complete.
+ """
+ if self.onboard_done:
+ msg = "Onboarding already completed"
+ raise InvalidDataError(msg)
+
+ self.set(CONF_ONBOARD_DONE, True)
+ self.save(immediate=True)
+ LOGGER.info("Onboarding completed")
+
+ # (re)Announce to Home Assistant if running as addon
+ if self.mass.running_as_hass_addon:
+ await self.mass.webserver._announce_to_homeassistant()
+
async def close(self) -> None:
"""Handle logic on server stop."""
if not self._timer_handle:
# loading failed, remove config
self.remove(conf_key)
raise
+ # mark onboard as complete as soon as the first provider is added
+ await self.set_onboard_complete()
return config
### First-Time Setup Flow
-1. **Initial State**: No users exist, `onboard_done = false`
+1. **Initial State**: No users exist
2. **Setup Required**: User is redirected to `/setup`
3. **Admin Creation**: User creates the first admin account with username/password
-4. **Onboarding Complete**: `onboard_done` is set to `true`
+4. **Setup completes** User gets redirected to the frontend
+5. **Onboarding wizard** The frontend shows the onboarding wizard if it detects 'onboard_done' is False
+4. **Onboarding Complete**: User completes onboarding and the `onboard_done` flag is set to `true`
+
+### First-Time Setup Flow when HA Ingress is used
+
+1. **Initial State**: No users exist
+2. **Auto user creation**: User is auto created based on HA user
+4. **Setup completes** User gets redirected to the frontend
+5. **Onboarding wizard** The frontend shows the onboarding wizard if it detects 'onboard_done' is False
+4. **Onboarding Complete**: User completes onboarding and the `onboard_done` flag is set to `true`
### Login Flow (Standard)
"summary": "Initial server setup",
"description": (
"Handle initial setup of the Music Assistant server including creating "
- "the first admin user. Only accessible when no users exist "
- "(onboard_done=false)."
+ "the first admin user. Only accessible when no users exist."
),
"operationId": "setup",
"tags": ["Server"],
from music_assistant.constants import (
CONF_AUTH_ALLOW_SELF_REGISTRATION,
- CONF_ONBOARD_DONE,
+ DB_TABLE_PLAYLOG,
HOMEASSISTANT_SYSTEM_USER,
MASS_LOGGER_NAME,
)
self.logger = LOGGER
# Pending OAuth sessions for remote clients (session_id -> token)
self._pending_oauth_sessions: dict[str, str | None] = {}
+ self._has_users: bool = False
async def setup(self) -> None:
"""Initialize the authentication manager."""
# Setup login providers based on config
await self._setup_login_providers(allow_self_registration)
- # Migration: Reset onboard_done if no users exist
- # This handles migration from existing setups (pre schema 28)
- # where authentication was still optional - or if the auth db was reset.
- # note that we do not do this if running as HA addon, because Ingress
- # users are created automatically
- if not self.mass.running_as_hass_addon and not await self.has_users():
- self.logger.warning(
- "Authentication is mandatory but no users exist. "
- "Resetting onboard_done to redirect to setup."
- )
- self.mass.config.set(CONF_ONBOARD_DONE, False)
- self.mass.config.save(immediate=True)
+ self._has_users = await self.database.get_count("users") > 0
self.logger.info(
"Authentication manager initialized (providers=%d)", len(self.login_providers)
if self.database:
await self.database.close()
+ @property
+ def has_users(self) -> bool:
+ """Check if any users exist in the system."""
+ return self._has_users
+
async def _setup_database(self) -> None:
"""Set up database schema and handle migrations."""
# Always create tables if they don't exist
del self.login_providers["homeassistant"]
self.logger.info("Home Assistant OAuth provider removed (HA provider not available)")
- async def has_users(self) -> bool:
- """Check if any users exist in the system."""
- count = await self.database.get_count("users")
- return count > 0
-
async def authenticate_with_credentials(
self, provider_id: str, credentials: dict[str, Any]
) -> AuthResult:
:param player_filter: Optional list of player IDs user has access to.
:param provider_filter: Optional list of provider instance IDs user has access to.
"""
- username = normalize_username(username)
+ normalized_username = normalize_username(username)
+
+ # Check if this is the first non-system user
+ is_first_user = not await self._has_non_system_users()
user_id = secrets.token_urlsafe(32)
created_at = utc()
user_data = {
"user_id": user_id,
- "username": username,
+ "username": normalized_username,
"role": role.value,
"enabled": True,
"created_at": created_at.isoformat(),
await self.database.insert("users", user_data)
- return User(
+ user = User(
user_id=user_id,
- username=username,
+ username=normalized_username,
role=role,
enabled=True,
created_at=created_at,
provider_filter=provider_filter,
)
+ # If this is the first non-system user, migrate playlog entries to them
+ if is_first_user and normalized_username != HOMEASSISTANT_SYSTEM_USER:
+ self._has_users = True
+ await self._migrate_playlog_to_first_user(user_id)
+
+ return user
+
+ async def _has_non_system_users(self) -> bool:
+ """Check if any non-system users exist."""
+ user_rows = await self.database.get_rows("users", limit=10)
+ return any(row["username"] != HOMEASSISTANT_SYSTEM_USER for row in user_rows)
+
+ async def _migrate_playlog_to_first_user(self, user_id: str) -> None:
+ """
+ Migrate all existing playlog entries to the first user.
+
+ This is called automatically when the first non-system user is created.
+ All existing playlog entries (which have NULL userid) will be updated
+ to belong to this first user.
+
+ :param user_id: The user ID of the first user.
+ """
+ try:
+ # Update all playlog entries with NULL userid to this user
+ await self.mass.music.database.execute(
+ f"UPDATE {DB_TABLE_PLAYLOG} SET userid = :userid WHERE userid IS NULL",
+ {"userid": user_id},
+ )
+ await self.mass.music.database.commit()
+ self.logger.info("Migrated existing playlog entries to first user: %s", user_id)
+ except Exception as err:
+ self.logger.warning("Failed to migrate playlog entries: %s", err)
+
async def get_homeassistant_system_user(self) -> User:
"""
Get or create the Home Assistant system user.
import asyncio
import hashlib
import html
-import json
import os
import ssl
import tempfile
from mashumaro.exceptions import MissingField
from music_assistant_frontend import where as locate_frontend
from music_assistant_models.api import CommandMessage
-from music_assistant_models.auth import AuthProviderType, UserRole
+from music_assistant_models.auth import UserRole
from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
from music_assistant_models.enums import ConfigEntryType
CONF_AUTH_ALLOW_SELF_REGISTRATION,
CONF_BIND_IP,
CONF_BIND_PORT,
- CONF_ONBOARD_DONE,
- DB_TABLE_PLAYLOG,
RESOURCES_DIR,
VERBOSE_LOG_LEVEL,
)
bind_ip = cast("str | None", config.get_value(CONF_BIND_IP))
# print a big fat message in the log where the webserver is running
# because this is a common source of issues for people with more complex setups
- if not self.mass.config.onboard_done:
+ if not self.auth.has_users:
self.logger.warning(
"\n\n################################################################################\n"
"### SETUP REQUIRED ###\n"
)
else:
self.logger.info(
+ "\n"
"################################################################################\n"
"\n"
"Webserver available on: %s\n"
async def _handle_jsonrpc_api_command(self, request: web.Request) -> web.Response:
"""Handle incoming JSON RPC API command."""
- # Block until onboarding is complete
- if not self.mass.config.onboard_done:
+ # Fail early if we don't have any users yet
+ if not self.auth.has_users:
return web.Response(status=503, text="Setup required")
if not request.can_read_body:
return web.Response(status=400, text="Body required")
return await self._server.serve_static(swagger_html_path, request)
async def _handle_index(self, request: web.Request) -> web.StreamResponse:
- """Handle request for index page with onboarding check."""
+ """Handle request for index page (Vue frontend)."""
# If not yet onboarded, redirect to setup
- if not self.mass.config.onboard_done or (
- not self.auth.has_users() and not is_request_from_ingress(request)
- ):
- # Preserve return_url parameter if present (will be passed back after setup)
- return_url = request.query.get("return_url")
- if return_url:
- quoted_return = urllib.parse.quote(return_url, safe="")
- setup_url = f"setup?return_url={quoted_return}"
- else:
- # No return URL - just redirect to setup without the parameter
- setup_url = "setup"
- return web.Response(status=302, headers={"Location": setup_url})
-
+ if not self.auth.has_users and not is_request_from_ingress(request):
+ return web.Response(status=302, headers={"Location": "setup"})
# Serve the Vue frontend index.html
return await self._server.serve_static(self._index_path, request)
async def _handle_login_page(self, request: web.Request) -> web.Response:
"""Handle request for login page (external client OAuth callback scenario)."""
- # If not yet onboarded, redirect to setup
- if not self.mass.config.onboard_done:
+ if not self.auth.has_users:
+ # not yet onboarded (no first admin user exists), redirect to setup
return_url = request.query.get("return_url", "")
device_name = request.query.get("device_name", "")
setup_url = (
else "/setup"
)
return web.Response(status=302, headers={"Location": setup_url})
-
# Serve login page for external clients
login_html_path = str(RESOURCES_DIR.joinpath("login.html"))
async with aiofiles.open(login_html_path) as f:
async def _handle_auth_login(self, request: web.Request) -> web.Response:
"""Handle login request."""
# Block until onboarding is complete
- if not self.mass.config.onboard_done:
+ if not self.auth.has_users:
return web.json_response(
{"success": False, "error": "Setup required"},
status=403,
else:
return_url = "/login"
# check if setup is already completed
- if self.mass.config.onboard_done:
+ if self.auth.has_users:
# Setup already completed, redirect to login (or provided return_url)
return web.Response(status=302, headers={"Location": return_url})
- # Serve setup page
+
setup_html_path = str(RESOURCES_DIR.joinpath("setup.html"))
async with aiofiles.open(setup_html_path) as f:
html_content = await f.read()
- # Check if this is from Ingress - if so, pre-fill user info
- if is_request_from_ingress(request):
- ingress_username = request.headers.get("X-Remote-User-Name", "")
- ingress_display_name = request.headers.get("X-Remote-User-Display-Name", "")
-
- # Inject ingress user info into the page (use json.dumps to escape properly)
- html_content = html_content.replace(
- "const deviceName = urlParams.get('device_name');",
- f"const deviceName = urlParams.get('device_name');\n"
- f" const ingressUsername = {json.dumps(ingress_username)};\n"
- f" const ingressDisplayName = {json.dumps(ingress_display_name)};",
- )
-
return web.Response(text=html_content, content_type="text/html")
async def _handle_setup(self, request: web.Request) -> web.Response:
- """Handle first-time setup request to create admin user."""
- if self.mass.config.onboard_done:
+ """Handle first-time setup request to create admin user (non-ingress only)."""
+ if self.auth.has_users:
return web.json_response(
{"success": False, "error": "Setup already completed"}, status=400
)
body = await request.json()
username = body.get("username", "").strip()
password = body.get("password", "")
- from_ingress = body.get("from_ingress", False)
- display_name = body.get("display_name")
-
- # Check if this is a valid ingress request (from_ingress flag + actual ingress headers)
- is_valid_ingress = from_ingress and is_request_from_ingress(request)
# Validation
if not username or len(username) < 2:
{"success": False, "error": "Username must be at least 2 characters"}, status=400
)
- # Password is only required for non-ingress users
- if not is_valid_ingress and (not password or len(password) < 8):
+ if not password or len(password) < 8:
return web.json_response(
{"success": False, "error": "Password must be at least 8 characters"}, status=400
)
try:
- if is_valid_ingress:
- # Ingress setup: Create user with HA provider link only (no password required)
- ha_user_id = request.headers.get("X-Remote-User-ID")
- if not ha_user_id:
- return web.json_response(
- {"success": False, "error": "Missing Home Assistant user ID"}, status=400
- )
-
- # Create admin user without password
- user = await self.auth.create_user(
- username=username,
- role=UserRole.ADMIN,
- display_name=display_name,
+ builtin_provider = self.auth.login_providers.get("builtin")
+ if not builtin_provider:
+ return web.json_response(
+ {"success": False, "error": "Built-in auth provider not available"},
+ status=500,
)
- # Link to Home Assistant provider
- await self.auth.link_user_to_provider(
- user, AuthProviderType.HOME_ASSISTANT, ha_user_id
+ if not isinstance(builtin_provider, BuiltinLoginProvider):
+ return web.json_response(
+ {"success": False, "error": "Built-in provider configuration error"},
+ status=500,
)
- else:
- # Non-ingress setup: Create user with password
- builtin_provider = self.auth.login_providers.get("builtin")
- if not builtin_provider:
- return web.json_response(
- {"success": False, "error": "Built-in auth provider not available"},
- status=500,
- )
-
- if not isinstance(builtin_provider, BuiltinLoginProvider):
- return web.json_response(
- {"success": False, "error": "Built-in provider configuration error"},
- status=500,
- )
- # Create admin user with password
- user = await builtin_provider.create_user_with_password(
- username, password, role=UserRole.ADMIN, display_name=display_name
- )
+ # Create admin user with password
+ user = await builtin_provider.create_user_with_password(
+ username, password, role=UserRole.ADMIN
+ )
# Create token for the new admin
device_name = body.get(
)
token = await self.auth.create_token(user, device_name)
- # Migrate existing playlog entries to this first user
- await self._migrate_playlog_to_first_user(user.user_id)
-
- # Mark onboarding as complete
- self.mass.config.set(CONF_ONBOARD_DONE, True)
- self.mass.config.save(immediate=True)
-
self.logger.info("First admin user created: %s", username)
- # Announce to Home Assistant now that onboarding is complete
- if self.mass.running_as_hass_addon:
- await self._announce_to_homeassistant()
-
+ # Return token - frontend will complete onboarding via config/onboard_complete
return web.json_response(
{
"success": True,
{"success": False, "error": f"Setup failed: {e!s}"}, status=500
)
- async def _migrate_playlog_to_first_user(self, user_id: str) -> None:
- """
- Migrate all existing playlog entries to the first user.
-
- This is called during onboarding when the first admin user is created.
- All existing playlog entries (which have NULL userid) will be updated
- to belong to this first user.
-
- :param user_id: The user ID of the first admin user.
- """
- try:
- # Update all playlog entries with NULL userid to this user
- await self.mass.music.database.execute(
- f"UPDATE {DB_TABLE_PLAYLOG} SET userid = :userid WHERE userid IS NULL",
- {"userid": user_id},
- )
- await self.mass.music.database.commit()
- self.logger.info("Migrated existing playlog entries to first user: %s", user_id)
- except Exception as err:
- self.logger.warning("Failed to migrate playlog entries: %s", err)
-
async def _announce_to_homeassistant(self) -> None:
"""Announce Music Assistant Ingress server to Home Assistant via Supervisor API."""
supervisor_token = os.environ["SUPERVISOR_TOKEN"]
AuthProviderType.HOME_ASSISTANT, ingress_user_id
)
if not user:
- # Only auto-create users after onboarding is complete
- if not mass.config.onboard_done:
- return None
-
user = await mass.webserver.auth.get_user_by_username(ingress_username)
if not user:
role = await get_ha_user_role(mass, ingress_user_id)
self._using_ha_cloud = bool(ha_cloud_available and ice_servers)
mode = "optimized" if self._using_ha_cloud else "basic"
- self.logger.info("Starting remote access in %s mode (ID: %s)", mode, self._remote_id)
+ self.logger.info("Starting remote access in %s mode", mode)
self.gateway = WebRTCGateway(
http_session=self.mass.http_session,
async def start(self) -> None:
"""Start the WebRTC Gateway."""
- self.logger.info("Starting WebRTC Gateway with Remote ID: %s", self.remote_id)
+ self.logger.info("Starting WebRTC Gateway")
self.logger.debug("Signaling URL: %s", self.signaling_url)
self.logger.debug("Local WS URL: %s", self.local_ws_url)
self._running = True
pass
elif msg_type == "registered":
self._is_connected = True
- self.logger.info("Registered with signaling server as: %s", message.get("remoteId"))
+ self.logger.info("Registered with signaling server")
elif msg_type == "error":
error_msg = message.get("error") or message.get("message", "Unknown error")
self.logger.error("Signaling server error: %s", error_msg)
await self._send_message(server_info)
# Block until onboarding is complete
- if not self.mass.config.onboard_done and not self._is_ingress:
+ if not self.webserver.auth.has_users and not self._is_ingress:
await self._send_message(ErrorResultMessage("connection", 503, "Setup required"))
await wsock.close()
return wsock
)
if not user:
- # Only auto-create users after onboarding is complete
- if not self.mass.config.onboard_done:
- self._logger.warning("Ingress connection attempted before setup")
- return
-
# Check if a user with this username already exists
user = await self.webserver.auth.get_user_by_username(ingress_username)
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Music Assistant - Setup</title>
+ <title>Music Assistant - Create Admin Account</title>
<link rel="stylesheet" href="resources/common.css">
<style>
body {
font-weight: 400;
}
- /* Step indicator */
- .steps-indicator {
- display: flex;
- justify-content: center;
- margin-bottom: 32px;
- gap: 12px;
- }
-
- .step-dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: var(--border);
- transition: all 0.3s ease;
- }
-
- .step-dot.active {
- background: var(--primary);
- width: 32px;
- border-radius: 5px;
- }
-
- .step-dot.completed {
- background: var(--primary);
- opacity: 0.5;
- }
-
- /* Step content */
- .step {
- display: none;
- }
-
- .step.active {
- display: block;
- animation: fadeIn 0.3s ease;
- }
-
- @keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
-
- .step-header {
+ .header {
margin-bottom: 24px;
}
- .step-header h2 {
+ .header h2 {
color: var(--fg);
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
- .step-header p {
+ .header p {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.6;
}
- .info-box {
- background: var(--input-focus-bg);
- border-left: 3px solid var(--primary);
- padding: 16px 18px;
- margin-bottom: 24px;
- border-radius: 0 8px 8px 0;
- }
-
- .info-box.ingress {
- border-left-color: #4CAF50;
- }
-
- .info-box h3 {
- color: var(--primary);
- font-size: 14px;
- font-weight: 600;
- margin-bottom: 6px;
- }
-
- .info-box.ingress h3 {
- color: #4CAF50;
- }
-
- .info-box p {
- color: var(--text-secondary);
- font-size: 13px;
- line-height: 1.6;
- margin: 0;
- }
-
- .info-box p + p {
- margin-top: 8px;
- }
-
- .info-box ul {
- margin: 8px 0 0 0;
- padding-left: 20px;
- color: var(--text-secondary);
- font-size: 13px;
- line-height: 1.8;
- }
-
.password-requirements {
margin-top: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
- /* Buttons */
- .step-actions {
+ .form-actions {
margin-top: 24px;
- display: flex;
- gap: 12px;
}
.btn {
- flex: 1;
+ width: 100%;
padding: 15px;
border: none;
border-radius: 10px;
filter: none;
}
- .btn-secondary {
- background: var(--panel-secondary);
- color: var(--text-secondary);
- border: 1px solid var(--border);
- }
-
- .btn-secondary:hover {
- background: var(--input-bg);
- color: var(--fg);
- }
-
- /* Completion step */
- .completion-icon {
- width: 64px;
- height: 64px;
- margin: 0 auto 20px;
- background: var(--primary);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 32px;
- }
-
- .completion-message {
- text-align: center;
- }
-
- .completion-message h2 {
- color: var(--fg);
- font-size: 22px;
- font-weight: 600;
- margin-bottom: 12px;
- }
-
- .completion-message p {
- color: var(--text-secondary);
- font-size: 14px;
- line-height: 1.6;
- margin-bottom: 12px;
- }
-
- /* Loading state */
- .loading {
- display: none;
- text-align: center;
- padding: 30px 20px;
- }
-
- .loading.show {
- display: block;
- }
-
- .spinner {
- border: 2px solid var(--border);
- border-top: 2px solid var(--primary);
- border-radius: 50%;
- width: 36px;
- height: 36px;
- animation: spin 0.8s linear infinite;
- margin: 0 auto;
- }
-
- @keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
- }
-
- .loading p {
- margin-top: 16px;
- color: var(--text-secondary);
- font-size: 14px;
- }
</style>
</head>
<body>
<img src="logo.png" alt="Music Assistant">
</div>
<h1>Music Assistant</h1>
- <p id="logoSubtitle">Setup Wizard</p>
+ <p>Create Admin Account</p>
</div>
- <!-- Step indicator -->
- <div class="steps-indicator">
- <div class="step-dot active" data-step="0"></div>
- <div class="step-dot" data-step="1"></div>
- <div class="step-dot" data-step="2"></div>
- </div>
-
- <!-- Step 0: Welcome -->
- <div class="step active" data-step="0">
- <div class="step-header">
- <h2>Welcome to Music Assistant!</h2>
- <p>Let's get you started with your personal music server. This setup wizard will guide you through the initial configuration.</p>
- </div>
-
- <div class="info-box">
- <h3>What you'll set up:</h3>
- <p><strong>Step 1:</strong> Create your administrator account</p>
- <p><strong>Step 2:</strong> Complete the setup process</p>
- </div>
-
- <div class="step-actions">
- <button type="button" class="btn btn-primary" onclick="nextStep()">Get Started</button>
- </div>
+ <div class="header">
+ <h2>Welcome!</h2>
+ <p>Create an administrator account to get started with Music Assistant.</p>
</div>
- <!-- Step 1: Create Admin Account -->
- <div class="step" data-step="1">
- <div class="step-header">
- <h2>Create Administrator Account</h2>
- <p>Your admin credentials will be used to access the Music Assistant web interface and mobile apps.</p>
- </div>
-
- <div class="info-box ingress" id="ingressAccountInfo" style="display: none;">
- <h3>Home Assistant Login</h3>
- <p>You are automatically logged in using your Home Assistant account. If you ever want to access Music Assistant outside of Home Assistant, you can set up a password later in the profile settings.</p>
+ <div class="error-message" id="errorMessage"></div>
+
+ <form id="setupForm">
+ <div class="form-group">
+ <label for="username">Username</label>
+ <input
+ type="text"
+ id="username"
+ name="username"
+ required
+ autocomplete="username"
+ placeholder="Enter your username"
+ minlength="3"
+ >
</div>
- <div class="error-message" id="errorMessage"></div>
-
- <form id="setupForm">
- <div class="form-group">
- <label for="username">Username</label>
- <input
- type="text"
- id="username"
- name="username"
- required
- autocomplete="username"
- placeholder="Enter your username"
- minlength="3"
- >
- </div>
-
- <div class="form-group">
- <label for="password">Password</label>
- <input
- type="password"
- id="password"
- name="password"
- required
- autocomplete="new-password"
- placeholder="Enter a secure password"
- minlength="8"
- >
- <div class="password-requirements">
- Minimum 8 characters recommended
- </div>
- </div>
-
- <div class="form-group">
- <label for="confirmPassword">Confirm Password</label>
- <input
- type="password"
- id="confirmPassword"
- name="confirmPassword"
- required
- autocomplete="new-password"
- placeholder="Re-enter your password"
- >
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input
+ type="password"
+ id="password"
+ name="password"
+ required
+ autocomplete="new-password"
+ placeholder="Enter a secure password"
+ minlength="8"
+ >
+ <div class="password-requirements">
+ Minimum 8 characters
</div>
-
- <div class="step-actions">
- <button type="button" class="btn btn-secondary" onclick="previousStep()">Back</button>
- <button type="submit" class="btn btn-primary" id="createAccountBtn">Create Account</button>
- </div>
- </form>
-
- <div class="loading" id="loading">
- <div class="spinner"></div>
- <p>Creating your account...</p>
</div>
- </div>
- <!-- Step 2: Completion -->
- <div class="step" data-step="2">
- <div class="completion-icon">
- ✓
- </div>
- <div class="completion-message">
- <h2>Setup Complete!</h2>
- <p>Your Music Assistant server has been successfully configured and is ready to use.</p>
- <p>You can now start adding music providers and connecting your speakers to begin enjoying your music library.</p>
+ <div class="form-group">
+ <label for="confirmPassword">Confirm Password</label>
+ <input
+ type="password"
+ id="confirmPassword"
+ name="confirmPassword"
+ required
+ autocomplete="new-password"
+ placeholder="Re-enter your password"
+ >
</div>
- <div class="step-actions">
- <button type="button" class="btn btn-primary" onclick="completeSetup()">Continue to Music Assistant</button>
+ <div class="form-actions">
+ <button type="submit" class="btn btn-primary" id="createAccountBtn">Create Account</button>
</div>
- </div>
+ </form>
+
</div>
<script>
- // Get query parameters
const urlParams = new URLSearchParams(window.location.search);
const deviceName = urlParams.get('device_name');
const returnUrl = urlParams.get('return_url');
- let currentStep = 0;
- let authToken = null;
-
- // Validate URL to prevent XSS attacks
- // Note: Server-side validation is the primary security layer
function isValidRedirectUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url, window.location.origin);
- // Allow http, https, and custom mobile app schemes
const allowedProtocols = ['http:', 'https:', 'musicassistant:'];
return allowedProtocols.includes(parsed.protocol);
} catch {
}
}
- // Check if this is an Ingress setup (variables injected by server)
- const isIngressSetup = typeof ingressUsername !== 'undefined' && ingressUsername;
-
- // Initialize UI
- if (isIngressSetup) {
- // Show ingress-specific information
- document.getElementById('ingressAccountInfo').style.display = 'block';
-
- // Pre-fill and disable username field (provided by Home Assistant)
- const usernameField = document.getElementById('username');
- usernameField.value = ingressUsername;
- usernameField.disabled = true;
-
- // Hide password fields for ingress users (they don't need to set a password)
- document.querySelector('.form-group:has(#password)').style.display = 'none';
- document.querySelector('.form-group:has(#confirmPassword)').style.display = 'none';
-
- // Remove required attribute from password fields for HTML5 validation
- document.getElementById('password').removeAttribute('required');
- document.getElementById('confirmPassword').removeAttribute('required');
-
- // Update step header for ingress users
- const stepHeader = document.querySelector('.step[data-step="1"] .step-header');
- stepHeader.querySelector('h2').textContent = 'Confirm Your Account';
- stepHeader.querySelector('p').textContent = 'Your account will be linked to your Home Assistant user.';
-
- // Update button text for ingress users
- document.getElementById('createAccountBtn').textContent = 'Complete Setup';
- }
-
- function updateStepIndicator() {
- document.querySelectorAll('.step-dot').forEach((dot, index) => {
- dot.classList.remove('active', 'completed');
- if (index === currentStep) {
- dot.classList.add('active');
- } else if (index < currentStep) {
- dot.classList.add('completed');
- }
- });
- }
-
- function showStep(stepNumber) {
- document.querySelectorAll('.step').forEach(step => {
- step.classList.remove('active');
- });
- document.querySelector(`.step[data-step="${stepNumber}"]`).classList.add('active');
- currentStep = stepNumber;
- updateStepIndicator();
-
- // Update logo subtitle
- const subtitles = ['Setup Wizard', 'Create Account', 'All Set!'];
- document.getElementById('logoSubtitle').textContent = subtitles[stepNumber];
- }
-
- function nextStep() {
- if (currentStep < 2) {
- showStep(currentStep + 1);
- }
- }
-
- function previousStep() {
- if (currentStep > 0) {
- showStep(currentStep - 1);
- }
- }
-
function showError(message) {
const errorMessage = document.getElementById('errorMessage');
errorMessage.textContent = message;
errorMessage.classList.remove('show');
}
- function setLoading(isLoading) {
- const form = document.getElementById('setupForm');
- const loading = document.getElementById('loading');
+ function redirectWithToken(token) {
+ if (returnUrl && isValidRedirectUrl(returnUrl)) {
+ let finalUrl = returnUrl;
+
+ if (returnUrl.includes('#')) {
+ const parts = returnUrl.split('#', 2);
+ const basePart = parts[0];
+ const hashPart = parts[1];
+ const separator = basePart.includes('?') ? '&' : '?';
+ finalUrl = `${basePart}${separator}code=${encodeURIComponent(token)}&onboard=true#${hashPart}`;
+ } else {
+ const separator = returnUrl.includes('?') ? '&' : '?';
+ finalUrl = `${returnUrl}${separator}code=${encodeURIComponent(token)}&onboard=true`;
+ }
- if (isLoading) {
- form.style.display = 'none';
- loading.classList.add('show');
+ window.location.href = finalUrl;
} else {
- form.style.display = 'block';
- loading.classList.remove('show');
+ window.location.href = `./?code=${encodeURIComponent(token)}&onboard=true`;
}
}
- // Handle form submission
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
- // Validation
if (username.length < 3) {
showError('Username must be at least 3 characters long');
return;
}
- // Password validation only for non-ingress users
- if (!isIngressSetup) {
- if (password.length < 8) {
- showError('Password must be at least 8 characters long');
- return;
- }
+ if (password.length < 8) {
+ showError('Password must be at least 8 characters long');
+ return;
+ }
- if (password !== confirmPassword) {
- showError('Passwords do not match');
- return;
- }
+ if (password !== confirmPassword) {
+ showError('Passwords do not match');
+ return;
}
- setLoading(true);
+ const submitBtn = document.getElementById('createAccountBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Creating Account...';
try {
const requestBody = {
password: password,
};
- // Include device_name if provided via query parameter
if (deviceName) {
requestBody.device_name = deviceName;
}
- // Include Ingress context if applicable
- if (isIngressSetup) {
- requestBody.from_ingress = true;
- requestBody.display_name = ingressDisplayName;
- }
-
const response = await fetch('setup', {
method: 'POST',
headers: {
const data = await response.json();
if (response.ok && data.success) {
- // Store token for later
- authToken = data.token;
-
- // Move to completion step
- setLoading(false);
- showStep(2);
+ redirectWithToken(data.token);
} else {
- setLoading(false);
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Create Account';
showError(data.error || 'Setup failed. Please try again.');
}
} catch (error) {
- setLoading(false);
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Create Account';
showError('Network error. Please check your connection and try again.');
console.error('Setup error:', error);
}
});
- function completeSetup() {
- if (returnUrl && isValidRedirectUrl(returnUrl)) {
- // Insert code and onboard parameters before any hash fragment
- let finalUrl = returnUrl;
-
- if (returnUrl.includes('#')) {
- // Split URL by hash
- const parts = returnUrl.split('#', 2);
- const basePart = parts[0];
- const hashPart = parts[1];
- const separator = basePart.includes('?') ? '&' : '?';
- finalUrl = `${basePart}${separator}code=${encodeURIComponent(authToken)}&onboard=true#${hashPart}`;
- } else {
- const separator = returnUrl.includes('?') ? '&' : '?';
- finalUrl = `${returnUrl}${separator}code=${encodeURIComponent(authToken)}&onboard=true`;
- }
-
- window.location.href = finalUrl;
- } else {
- // No return URL - use relative path to current directory
- window.location.href = `./?code=${encodeURIComponent(authToken)}&onboard=true`;
- }
- }
-
- // Clear error on input
document.querySelectorAll('input').forEach(input => {
input.addEventListener('input', hideError);
});
:param auth_manager: AuthenticationManager instance.
"""
- has_users = await auth_manager.has_users()
+ has_users = auth_manager.has_users
assert has_users is False
assert user.user_id is not None
# Verify user exists in database
- has_users = await auth_manager.has_users()
+ has_users = auth_manager.has_users
assert has_users is True