From 21dfbb39b0c9bd63f1228aee709d28fcdc4ec2eb Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 22:10:33 -0700 Subject: [PATCH] Native login (increment 3/3): login screen, set-password, Settings toggle, logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged (none of this appears unless security.require_login is on). - Login screen overlay (reuses the launch-PIN styling): username + password → /api/auth/login → reload into the app. Shown when /api/profiles/current reports login_required (checked before profile selection). - POST /api/profiles//set-password (admin, or self) to set/clear a login password, distinct from the PIN. - Settings → Security: "Login password (admin account)" field + a "Require login" toggle (with the anti-lockout note). Wired into the existing settings load/save. - Sign-out button in the profile bar, revealed only in login mode (login_mode flag on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload. Tests: set-password sets/clears + verifies; /api/profiles/current signals login_required. 20 login/password tests pass; 64 script-split integrity pass. Remaining (small follow-up): a password field in the Manage Profiles edit form so admins can set OTHER profiles' passwords from the UI (the endpoint already exists). --- tests/test_login_endpoints.py | 18 +++++++++ web_server.py | 25 ++++++++++++ webui/index.html | 38 ++++++++++++++++++ webui/static/init.js | 76 +++++++++++++++++++++++++++++++++++ webui/static/settings.js | 3 ++ 5 files changed, 160 insertions(+) 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, } };