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).
This commit is contained in:
parent
92cbef90f9
commit
21dfbb39b0
5 changed files with 160 additions and 0 deletions
|
|
@ -80,3 +80,21 @@ def test_cannot_enable_login_without_admin_password(client):
|
||||||
r = client.post('/api/settings', json={'security': {'require_login': True}})
|
r = client.post('/api/settings', json={'security': {'require_login': True}})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert 'password' in r.get_json().get('error', '').lower()
|
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():
|
def get_current_profile():
|
||||||
"""Get the currently selected profile from session"""
|
"""Get the currently selected profile from session"""
|
||||||
try:
|
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')
|
pid = session.get('profile_id')
|
||||||
if not pid:
|
if not pid:
|
||||||
return jsonify({'success': False, 'error': 'No profile selected'}), 200
|
return jsonify({'success': False, 'error': 'No profile selected'}), 200
|
||||||
|
|
@ -25305,6 +25311,7 @@ def get_current_profile():
|
||||||
'success': True,
|
'success': True,
|
||||||
'profile': profile,
|
'profile': profile,
|
||||||
'launch_pin_required': bool(require_pin) and not pin_verified,
|
'launch_pin_required': bool(require_pin) and not pin_verified,
|
||||||
|
'login_mode': _require_login_enabled(),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -25469,6 +25476,24 @@ def set_profile_pin(profile_id):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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 ---
|
# --- Per-Profile ListenBrainz Settings ---
|
||||||
|
|
||||||
def _get_lb_credentials_for_profile(profile_id=None):
|
def _get_lb_credentials_for_profile(profile_id=None):
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,19 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Profile Picker Overlay -->
|
||||||
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
|
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
|
||||||
<div class="profile-picker-container">
|
<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">
|
<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>
|
<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>
|
||||||
|
<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>
|
||||||
</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).
|
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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Discovery Settings -->
|
<!-- Discovery Settings -->
|
||||||
|
|
|
||||||
|
|
@ -340,9 +340,21 @@ async function initProfileSystem() {
|
||||||
// Check if a session already has a profile selected
|
// Check if a session already has a profile selected
|
||||||
const currentRes = await fetch('/api/profiles/current');
|
const currentRes = await fetch('/api/profiles/current');
|
||||||
const currentData = await currentRes.json();
|
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) {
|
if (currentData.success && currentData.profile) {
|
||||||
setCurrentProfile(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
|
// Check if launch PIN is required
|
||||||
if (currentData.launch_pin_required) {
|
if (currentData.launch_pin_required) {
|
||||||
showLaunchPinScreen();
|
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 ─────────────────────────────────────────────
|
// ── Launch PIN Lock Screen ─────────────────────────────────────────────
|
||||||
|
|
||||||
function showLaunchPinScreen() {
|
function showLaunchPinScreen() {
|
||||||
|
|
@ -451,6 +505,28 @@ function showLaunchPinScreen() {
|
||||||
|
|
||||||
// ── Security Settings Helpers ──────────────────────────────────────────
|
// ── 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() {
|
async function saveSecurityPin() {
|
||||||
const pin = document.getElementById('security-new-pin').value;
|
const pin = document.getElementById('security-new-pin').value;
|
||||||
const confirm = document.getElementById('security-confirm-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;
|
if (trustProxy) trustProxy.checked = settings.security?.trust_reverse_proxy || false;
|
||||||
const authHeader = document.getElementById('security-auth-proxy-header');
|
const authHeader = document.getElementById('security-auth-proxy-header');
|
||||||
if (authHeader) authHeader.value = settings.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
|
// Check if admin has a PIN set
|
||||||
const profilesRes = await fetch('/api/profiles');
|
const profilesRes = await fetch('/api/profiles');
|
||||||
|
|
@ -3154,6 +3156,7 @@ async function saveSettings(quiet = false) {
|
||||||
cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '',
|
cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '',
|
||||||
trust_reverse_proxy: document.getElementById('security-trust-proxy')?.checked || false,
|
trust_reverse_proxy: document.getElementById('security-trust-proxy')?.checked || false,
|
||||||
auth_proxy_header: document.getElementById('security-auth-proxy-header')?.value?.trim() || '',
|
auth_proxy_header: document.getElementById('security-auth-proxy-header')?.value?.trim() || '',
|
||||||
|
require_login: document.getElementById('security-require-login')?.checked || false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue