diff --git a/tests/test_login_endpoints.py b/tests/test_login_endpoints.py index 4b9854b0..f943a9ca 100644 --- a/tests/test_login_endpoints.py +++ b/tests/test_login_endpoints.py @@ -80,3 +80,21 @@ def test_cannot_enable_login_without_admin_password(client): r = client.post('/api/settings', json={'security': {'require_login': True}}) assert r.status_code == 400 assert 'password' in r.get_json().get('error', '').lower() + + +def test_set_password_endpoint(client): + db = web_server.get_database() + pid = db.create_profile(name='SetPwTest') + # admin (default session) can set any profile's login password + r = client.post(f'/api/profiles/{pid}/set-password', json={'password': 'newpw123'}) + body = r.get_json() + assert body['success'] is True and body['has_password'] is True + assert db.verify_profile_password(pid, 'newpw123') is True + # clearing it + assert client.post(f'/api/profiles/{pid}/set-password', json={'password': ''}).get_json()['has_password'] is False + + +def test_profiles_current_signals_login_required(client, monkeypatch): + _enable_login(monkeypatch) + body = client.get('/api/profiles/current').get_json() + assert body.get('login_required') is True # frontend uses this to show the sign-in screen diff --git a/web_server.py b/web_server.py index 8345b3ee..3f2e3866 100644 --- a/web_server.py +++ b/web_server.py @@ -25282,6 +25282,12 @@ def select_profile(): def get_current_profile(): """Get the currently selected profile from session""" try: + # Login mode: when on and the session isn't authenticated, tell the + # frontend to show the sign-in screen (this is checked before profile + # selection, since there's no profile until you log in). + if _require_login_enabled() and not session.get('login_authenticated', False): + return jsonify({'success': False, 'login_required': True}), 200 + pid = session.get('profile_id') if not pid: return jsonify({'success': False, 'error': 'No profile selected'}), 200 @@ -25305,6 +25311,7 @@ def get_current_profile(): 'success': True, 'profile': profile, 'launch_pin_required': bool(require_pin) and not pin_verified, + 'login_mode': _require_login_enabled(), }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -25469,6 +25476,24 @@ def set_profile_pin(profile_id): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles//set-password', methods=['POST']) +def set_profile_password_endpoint(profile_id): + """Set or clear a profile's LOGIN password (admin, or the profile itself). + Distinct from the quick-switch PIN.""" + try: + database = get_database() + current_pid = get_current_profile_id() + current = database.get_profile(current_pid) + if not current or (not current['is_admin'] and current_pid != profile_id): + return jsonify({'success': False, 'error': 'Unauthorized'}), 403 + data = request.json or {} + password = data.get('password', '') + ok = database.set_profile_password(profile_id, password) + return jsonify({'success': bool(ok), 'has_password': database.profile_has_password(profile_id)}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + # --- Per-Profile ListenBrainz Settings --- def _get_lb_credentials_for_profile(profile_id=None): diff --git a/webui/index.html b/webui/index.html index 3d90c7e6..4bbf1510 100644 --- a/webui/index.html +++ b/webui/index.html @@ -51,6 +51,19 @@ + + + @@ -6011,6 +6027,28 @@ If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in in front of SoulSync, enter the header it sets (e.g. Remote-User) and SoulSync will skip the launch PIN for already-authenticated requests. Only set this behind a proxy that strips any client-supplied copy of the header — otherwise it can be spoofed. Leave blank to disable (the default). + +
+ +
+ +
+ Set a password for the admin account, then turn on "Require login" below. Your username is your profile name. Set passwords for other profiles in Manage Profiles. +
+ + + +
+ +
+ +
+ When enabled, a sign-in screen replaces the profile picker + launch PIN — everyone signs in with their account name + password. Set the admin password above first (you can't enable this without one, to avoid locking yourself out). Best for instances exposed to the internet. +
+
diff --git a/webui/static/init.js b/webui/static/init.js index 71f94496..a032bc4b 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -340,9 +340,21 @@ async function initProfileSystem() { // Check if a session already has a profile selected const currentRes = await fetch('/api/profiles/current'); const currentData = await currentRes.json(); + // Login mode: show the sign-in screen and defer everything else until + // the user authenticates. + if (currentData.login_required) { + showLoginScreen(); + return false; + } if (currentData.success && currentData.profile) { setCurrentProfile(currentData.profile); + // Login mode → reveal the Sign out button in the profile bar. + if (currentData.login_mode) { + const lb = document.getElementById('logout-btn'); + if (lb) lb.style.display = ''; + } + // Check if launch PIN is required if (currentData.launch_pin_required) { showLaunchPinScreen(); @@ -387,6 +399,48 @@ async function initProfileSystem() { } } +// ── Login Screen (username/password mode) ────────────────────────────── + +function showLoginScreen() { + const overlay = document.getElementById('login-overlay'); + if (!overlay) return; + overlay.style.display = 'flex'; + const u = document.getElementById('login-username'); + if (u) setTimeout(() => u.focus(), 50); +} + +async function submitLogin() { + const username = (document.getElementById('login-username')?.value || '').trim(); + const password = document.getElementById('login-password')?.value || ''; + const errEl = document.getElementById('login-error'); + const btn = document.getElementById('login-submit'); + const showErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = 'block'; } }; + if (errEl) errEl.style.display = 'none'; + if (!username || !password) { showErr('Enter your username and password'); return; } + if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; } + try { + const res = await fetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (data.success) { + window.location.reload(); // authenticated → reload into the app + } else { + showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Sign in failed')); + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + } + } catch (e) { + showErr('Connection error'); + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + } +} + +async function soulsyncLogout() { + try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) { /* reload anyway */ } + window.location.reload(); +} + // ── Launch PIN Lock Screen ───────────────────────────────────────────── function showLaunchPinScreen() { @@ -451,6 +505,28 @@ function showLaunchPinScreen() { // ── Security Settings Helpers ────────────────────────────────────────── +async function saveLoginPassword() { + const input = document.getElementById('security-login-password'); + const msg = document.getElementById('security-login-password-msg'); + const password = input?.value || ''; + const show = (text, ok) => { + if (!msg) return; + msg.textContent = text; + msg.style.color = ok ? '#4caf50' : '#ff5252'; + msg.style.display = 'block'; + }; + if (!password || password.length < 6) { show('Password must be at least 6 characters', false); return; } + try { + const res = await fetch('/api/profiles/1/set-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (data.success) { show('Admin login password saved', true); if (input) input.value = ''; } + else show(data.error || 'Failed to save password', false); + } catch (e) { show('Connection error', false); } +} + async function saveSecurityPin() { const pin = document.getElementById('security-new-pin').value; const confirm = document.getElementById('security-confirm-pin').value; diff --git a/webui/static/settings.js b/webui/static/settings.js index 1a1a77ac..3a5967b6 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1403,6 +1403,8 @@ async function loadSettingsData() { if (trustProxy) trustProxy.checked = settings.security?.trust_reverse_proxy || false; const authHeader = document.getElementById('security-auth-proxy-header'); if (authHeader) authHeader.value = settings.security?.auth_proxy_header || ''; + const reqLogin = document.getElementById('security-require-login'); + if (reqLogin) reqLogin.checked = settings.security?.require_login || false; // Check if admin has a PIN set const profilesRes = await fetch('/api/profiles'); @@ -3154,6 +3156,7 @@ async function saveSettings(quiet = false) { cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '', trust_reverse_proxy: document.getElementById('security-trust-proxy')?.checked || false, auth_proxy_header: document.getElementById('security-auth-proxy-header')?.value?.trim() || '', + require_login: document.getElementById('security-require-login')?.checked || false, } };