Native login (increment 3/3): login screen, set-password, Settings toggle, logout

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/<id>/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).
pull/846/head
BoulderBadgeDad 3 weeks ago
parent 92cbef90f9
commit 21dfbb39b0

@ -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

@ -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/<int:profile_id>/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):

@ -51,6 +51,19 @@
</div>
</div>
<!-- Login Screen (username/password mode) -->
<div id="login-overlay" class="launch-pin-overlay" style="display: none;">
<div class="launch-pin-container">
<div class="launch-pin-icon">🔐</div>
<h2 class="launch-pin-title">Sign in to SoulSync</h2>
<p class="launch-pin-subtitle">Enter your account name and password</p>
<input type="text" id="login-username" class="launch-pin-input" placeholder="Username" autocomplete="username" maxlength="40" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="login-password" class="launch-pin-input" placeholder="Password" autocomplete="current-password" maxlength="200" style="letter-spacing: normal;" onkeydown="if(event.key==='Enter') submitLogin()">
<button id="login-submit" class="launch-pin-submit" onclick="submitLogin()">Sign in</button>
<p id="login-error" class="launch-pin-error" style="display: none;"></p>
</div>
</div>
<!-- Profile Picker Overlay -->
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
<div class="profile-picker-container">
@ -193,6 +206,9 @@
<button id="personal-settings-btn" class="personal-settings-trigger" onclick="event.stopPropagation(); openPersonalSettings()" title="My Settings">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button id="logout-btn" class="personal-settings-trigger" style="display:none" onclick="event.stopPropagation(); soulsyncLogout()" title="Sign out">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</div>
@ -6011,6 +6027,28 @@
If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in <em>in front of</em> SoulSync, enter the header it sets (e.g. <code>Remote-User</code>) and SoulSync will skip the launch PIN for already-authenticated requests. <strong>Only set this behind a proxy that strips any client-supplied copy of the header</strong> — otherwise it can be spoofed. Leave blank to disable (the default).
</div>
</div>
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:18px 0 14px;">
<div class="form-group">
<label>Login password (admin account):</label>
<div class="setting-help-text" style="margin-bottom: 8px;">
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 <strong>Manage Profiles</strong>.
</div>
<input type="password" id="security-login-password" placeholder="Set admin login password" maxlength="200" autocomplete="new-password" style="margin-bottom: 6px;">
<button class="auth-button" onclick="saveLoginPassword()">Save Password</button>
<p id="security-login-password-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="security-require-login">
<span>Require login (username + password)</span>
</label>
<div class="setting-help-text">
When enabled, a sign-in screen replaces the profile picker + launch PIN — everyone signs in with their account name + password. <strong>Set the admin password above first</strong> (you can't enable this without one, to avoid locking yourself out). Best for instances exposed to the internet.
</div>
</div>
</div>
<!-- Discovery Settings -->

@ -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;

@ -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,
}
};

Loading…
Cancel
Save