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}})
|
||||
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…
Reference in a new issue