/
/
/
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Login - Music Assistant</title>
7 <link rel="stylesheet" href="resources/common.css">
8 <style>
9 body {
10 min-height: 100vh;
11 display: flex;
12 align-items: center;
13 justify-content: center;
14 padding: 20px;
15 }
16
17 .login-container {
18 background: var(--panel);
19 border-radius: 16px;
20 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12),
21 0 0 0 1px var(--border);
22 width: 100%;
23 max-width: 400px;
24 padding: 48px 40px;
25 }
26
27 h1 {
28 text-align: center;
29 color: var(--fg);
30 font-size: 24px;
31 font-weight: 600;
32 letter-spacing: -0.5px;
33 margin-bottom: 8px;
34 }
35
36 .subtitle {
37 text-align: center;
38 color: var(--text-tertiary);
39 font-size: 14px;
40 margin-bottom: 32px;
41 }
42
43 .oauth-providers {
44 margin-top: 8px;
45 }
46 </style>
47</head>
48<body>
49 <div class="login-container">
50 <div class="logo">
51 <img src="logo.png" alt="Music Assistant">
52 </div>
53
54 <h1>Music Assistant</h1>
55 <p class="subtitle">External Client Authentication</p>
56
57 <div id="error" class="error"></div>
58
59 <form id="loginForm">
60 <div class="form-group">
61 <label for="username">Username</label>
62 <input type="text" id="username" name="username" required autofocus placeholder="Enter your username">
63 </div>
64
65 <div class="form-group">
66 <label for="password">Password</label>
67 <input type="password" id="password" name="password" required placeholder="Enter your password">
68 </div>
69
70 <button type="submit" class="btn btn-primary" id="loginBtn">
71 <span id="loginText">Sign In</span>
72 <span id="loginLoading" class="loading" style="display: none;"></span>
73 </button>
74 </form>
75
76 <div id="oauthProviders" class="oauth-providers">
77 <!-- OAuth providers will be inserted here -->
78 </div>
79 </div>
80
81 <script>
82 const API_BASE = window.location.origin;
83
84 // Get parameters from query string
85 const urlParams = new URLSearchParams(window.location.search);
86 const returnUrl = urlParams.get('return_url');
87 const deviceName = urlParams.get('device_name');
88 const joinCode = urlParams.get('join');
89
90 // Show error message
91 function showError(message) {
92 const errorEl = document.getElementById('error');
93 errorEl.textContent = message;
94 errorEl.classList.add('show');
95 }
96
97 // Hide error message
98 function hideError() {
99 document.getElementById('error').classList.remove('show');
100 }
101
102 // Set loading state
103 function setLoading(loading) {
104 const btn = document.getElementById('loginBtn');
105 const text = document.getElementById('loginText');
106 const loadingEl = document.getElementById('loginLoading');
107
108 btn.disabled = loading;
109 text.style.display = loading ? 'none' : 'inline';
110 loadingEl.style.display = loading ? 'inline-block' : 'none';
111 }
112
113 // Load OAuth providers
114 async function loadProviders() {
115 try {
116 const response = await fetch(`${API_BASE}/auth/providers`);
117 const providers = await response.json();
118
119 const oauthProviders = providers.filter(p => p.requires_redirect && p.provider_type !== 'builtin');
120
121 if (oauthProviders.length > 0) {
122 const container = document.getElementById('oauthProviders');
123
124 // Add divider
125 const divider = document.createElement('div');
126 divider.className = 'divider';
127 divider.innerHTML = '<span>Or continue with</span>';
128 container.appendChild(divider);
129
130 // Add OAuth buttons
131 oauthProviders.forEach(provider => {
132 const btn = document.createElement('button');
133 btn.className = 'btn btn-secondary';
134 btn.type = 'button';
135
136 let providerName = provider.provider_type;
137 if (provider.provider_type === 'homeassistant') {
138 providerName = 'Home Assistant';
139 } else if (provider.provider_type === 'google') {
140 providerName = 'Google';
141 }
142
143 btn.innerHTML = `<span>Sign in with ${providerName}</span>`;
144 btn.onclick = () => initiateOAuth(provider.provider_id);
145
146 container.appendChild(btn);
147 });
148 }
149 } catch (error) {
150 console.error('Failed to load providers:', error);
151 }
152 }
153
154 // Handle form submission
155 document.getElementById('loginForm').addEventListener('submit', async (e) => {
156 e.preventDefault();
157 hideError();
158 setLoading(true);
159
160 const username = document.getElementById('username').value;
161 const password = document.getElementById('password').value;
162
163 try {
164 const requestBody = {
165 provider_id: 'builtin',
166 credentials: { username, password }
167 };
168
169 // Include device_name if provided via query parameter
170 if (deviceName) {
171 requestBody.device_name = deviceName;
172 }
173
174 // Include return_url if present
175 if (returnUrl) {
176 requestBody.return_url = returnUrl;
177 }
178
179 const response = await fetch(`${API_BASE}/auth/login`, {
180 method: 'POST',
181 headers: {
182 'Content-Type': 'application/json'
183 },
184 body: JSON.stringify(requestBody)
185 });
186
187 const data = await response.json();
188
189 if (data.success) {
190 if (data.redirect_to) {
191 window.location.href = data.redirect_to;
192 } else {
193 window.location.href = `/?code=${encodeURIComponent(data.token)}`;
194 }
195 } else {
196 showError(data.error || 'Login failed');
197 }
198 } catch (error) {
199 showError('Network error. Please try again.');
200 } finally {
201 setLoading(false);
202 }
203 });
204
205 // Initiate OAuth flow
206 async function initiateOAuth(providerId) {
207 try {
208 let authorizeUrl = `${API_BASE}/auth/authorize?provider_id=${providerId}`;
209
210 // Pass return_url to authorize endpoint if present
211 if (returnUrl) {
212 authorizeUrl += `&return_url=${encodeURIComponent(returnUrl)}`;
213 }
214
215 const response = await fetch(authorizeUrl);
216 const data = await response.json();
217
218 if (data.authorization_url) {
219 // Redirect directly to OAuth provider
220 window.location.href = data.authorization_url;
221 } else {
222 showError('Failed to initiate OAuth flow');
223 }
224 } catch (error) {
225 showError('Network error. Please try again.');
226 }
227 }
228
229 // Clear error on input
230 document.querySelectorAll('input').forEach(input => {
231 input.addEventListener('input', hideError);
232 });
233
234 // Handle short code authentication (e.g., from QR code or link)
235 async function handleJoinCode() {
236 if (!joinCode) {
237 return false;
238 }
239
240 // Show loading state
241 const container = document.querySelector('.login-container');
242 container.innerHTML = `
243 <div class="logo">
244 <img src="logo.png" alt="Music Assistant">
245 </div>
246 <h1>Music Assistant</h1>
247 <p class="subtitle">Connecting...</p>
248 <div style="text-align: center; padding: 20px;">
249 <div class="loading" style="display: inline-block;"></div>
250 </div>
251 <div id="error" class="error"></div>
252 `;
253
254 try {
255 // Exchange the join code for a JWT token via JSON-RPC API
256 const response = await fetch(`${API_BASE}/api`, {
257 method: 'POST',
258 headers: {
259 'Content-Type': 'application/json'
260 },
261 body: JSON.stringify({
262 message_id: 'join_code_auth',
263 command: 'auth/join_code/exchange',
264 args: { code: joinCode.toUpperCase() }
265 })
266 });
267
268 const response_data = await response.json();
269
270 // JSON-RPC wraps results in 'result' field
271 const data = response_data.result || response_data;
272
273 if (data.success && data.access_token) {
274 // Redirect with the token
275 let redirectUrl = '/';
276 // Accept only same-origin paths; the URL API rejects prefix tricks like "//evil.com" or "/\evil.com".
277 if (typeof returnUrl === 'string') {
278 try {
279 const parsed = new URL(returnUrl, window.location.origin);
280 if (parsed.origin === window.location.origin) {
281 // Drop any #fragment so the code param below stays in the query
282 redirectUrl = parsed.pathname + parsed.search;
283 }
284 } catch (e) { /* keep default */ }
285 }
286 const separator = redirectUrl.includes('?') ? '&' : '?';
287 window.location.href = `${redirectUrl}${separator}code=${encodeURIComponent(data.access_token)}`;
288 return true;
289 } else {
290 // Show error - check both data and response_data for error info
291 const errorMsg = data.error || response_data.error_message || 'Invalid or expired code';
292 const errorEl = document.getElementById('error');
293 if (errorEl) {
294 errorEl.textContent = errorMsg;
295 errorEl.classList.add('show');
296 }
297 // Reload page without join code to show login form
298 setTimeout(() => {
299 urlParams.delete('join');
300 const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
301 window.location.href = newUrl;
302 }, 2000);
303 return false;
304 }
305 } catch (error) {
306 console.error('Join code authentication failed:', error);
307 // Show error and reload without join code
308 const errorEl = document.getElementById('error');
309 if (errorEl) {
310 errorEl.textContent = 'Authentication failed. Please try again.';
311 errorEl.classList.add('show');
312 }
313 setTimeout(() => {
314 urlParams.delete('join');
315 const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
316 window.location.href = newUrl;
317 }, 2000);
318 return false;
319 }
320 }
321
322 // On page load: try join code first, then load providers
323 (async function() {
324 if (joinCode) {
325 const handled = await handleJoinCode();
326 if (handled) return; // Successfully joined, redirecting
327 }
328 // No join code or it failed, show normal login
329 loadProviders();
330 })();
331 </script>
332</body>
333</html>
334