diff --git a/web_server.py b/web_server.py index 4b482162..7a455d40 100644 --- a/web_server.py +++ b/web_server.py @@ -4499,7 +4499,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -18888,6 +18888,17 @@ def get_version_info(): "• Deezer enrichment worker caches API calls through metadata cache", "• Per-source quality fallback toggles for streaming download sources" ] + }, + { + "title": "🔒 Launch PIN Lock Screen", + "description": "Protect SoulSync access with a PIN on every page load", + "features": [ + "• Toggle in Settings → Advanced → Security to require PIN on launch", + "• Full-screen lock overlay with PIN input — closing the tab requires re-entry", + "• PIN validated server-side against admin profile (bcrypt hashed)", + "• Inline PIN creation if admin has no PIN set", + "• Shake animation on wrong PIN, auto-focus input" + ] } ] } @@ -33163,6 +33174,10 @@ def select_profile(): return jsonify({'success': False, 'error': 'Invalid PIN'}), 401 session['profile_id'] = profile_id + # If PIN was just validated, also mark launch PIN as verified + # so the subsequent page reload doesn't ask again + if pin: + session['launch_pin_verified'] = True return jsonify({'success': True, 'profile': profile}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -33181,7 +33196,76 @@ def get_current_profile(): session.pop('profile_id', None) return jsonify({'success': False, 'error': 'Profile not found'}), 200 - return jsonify({'success': True, 'profile': profile}) + # Check if launch PIN is required + require_pin = config_manager.get('security.require_pin_on_launch', False) if config_manager else False + # Check if PIN was verified this page load, then consume the flag + pin_verified = session.pop('launch_pin_verified', False) + + return jsonify({ + 'success': True, + 'profile': profile, + 'launch_pin_required': bool(require_pin) and not pin_verified, + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/verify-launch-pin', methods=['POST']) +def verify_launch_pin(): + """Verify PIN for launch lock screen""" + try: + data = request.json or {} + pin = data.get('pin', '') + if not pin: + return jsonify({'success': False, 'error': 'PIN required'}), 401 + + database = get_database() + # Validate against admin profile (ID 1) + if not database.verify_profile_pin(1, pin): + return jsonify({'success': False, 'error': 'Invalid PIN'}), 401 + + session['launch_pin_verified'] = True + return jsonify({'success': True}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/profiles/reset-pin-via-credential', methods=['POST']) +def reset_pin_via_credential(): + """Reset admin PIN by verifying a known API credential""" + try: + data = request.json or {} + credential = (data.get('credential') or '').strip() + if not credential or len(credential) < 4: + return jsonify({'success': False, 'error': 'Enter a valid credential'}), 400 + + # Check credential against all stored API secrets/tokens + checks = [ + ('Spotify Client Secret', config_manager.get('spotify.client_secret', '')), + ('Tidal Client Secret', config_manager.get('tidal.client_secret', '')), + ('Plex Token', config_manager.get('plex.token', '')), + ('Jellyfin API Key', config_manager.get('jellyfin.api_key', '')), + ('Navidrome Password', config_manager.get('navidrome.password', '')), + ('ListenBrainz Token', config_manager.get('listenbrainz.token', '')), + ('AcoustID API Key', config_manager.get('acoustid.api_key', '')), + ('Last.fm API Secret', config_manager.get('lastfm.api_secret', '')), + ('Genius Access Token', config_manager.get('genius.access_token', '')), + ] + + matched = False + for name, stored in checks: + if stored and credential == stored: + matched = True + break + + if not matched: + return jsonify({'success': False, 'error': 'Credential does not match any configured service'}), 401 + + # Credential verified — clear admin PIN and disable launch lock + database = get_database() + database.update_profile(1, pin_hash=None) + config_manager.set('security.require_pin_on_launch', False) + session['launch_pin_verified'] = True + + return jsonify({'success': True, 'message': 'PIN cleared and lock screen disabled. You can set a new PIN in Settings.'}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/webui/index.html b/webui/index.html index c9906448..22e4f9d5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -11,6 +11,32 @@ + + + + +
+

🔒 Security

+ + + +
+ +
+ When enabled, a lock screen appears on every page load. PIN is verified against the admin account. Closing the browser tab requires re-entry. +
+
+ + +
+

🔍 Discovery Pool Settings

diff --git a/webui/static/helper.js b/webui/static/helper.js index e89c3f73..80b7a4a8 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Launch PIN Lock Screen', desc: 'Protect SoulSync with a PIN on every page load — toggle in Settings → Advanced', page: 'settings', selector: '.stg-tab[data-tab="advanced"]' }, { title: 'Interactive Help System', desc: 'Guided tours, search, shortcuts, setup tracking, troubleshooting — all from the ? button', selector: '#helper-float-btn' }, { title: 'Rich Artist Profiles', desc: 'Full-bleed hero section with bio, stats, genres, and service links', page: 'artists', selector: '#artists-search-input' }, { title: 'In Library Badges', desc: 'Search results show "In Library" badges for albums and tracks you already own', page: 'downloads', selector: '.enhanced-search-input-wrapper' }, diff --git a/webui/static/script.js b/webui/static/script.js index d1dc9288..3d677323 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -966,6 +966,13 @@ async function initProfileSystem() { if (currentData.success && currentData.profile) { currentProfile = currentData.profile; updateProfileIndicator(); + + // Check if launch PIN is required + if (currentData.launch_pin_required) { + showLaunchPinScreen(); + return false; // Defer app init until PIN verified + } + return true; // Profile already selected, skip picker } @@ -983,6 +990,15 @@ async function initProfileSystem() { if (profiles.length === 1) { // Only one profile — always auto-select (PIN only matters with multiple profiles) await selectProfile(profiles[0].id); + + // Re-check for launch PIN after auto-select + const recheck = await fetch('/api/profiles/current'); + const recheckData = await recheck.json(); + if (recheckData.launch_pin_required) { + showLaunchPinScreen(); + return false; + } + return true; } @@ -995,6 +1011,199 @@ async function initProfileSystem() { } } +// ── Launch PIN Lock Screen ───────────────────────────────────────────── + +function showLaunchPinScreen() { + const overlay = document.getElementById('launch-pin-overlay'); + if (!overlay) return; + overlay.style.display = 'flex'; + + const input = document.getElementById('launch-pin-input'); + const submit = document.getElementById('launch-pin-submit'); + const error = document.getElementById('launch-pin-error'); + + input.value = ''; + error.style.display = 'none'; + setTimeout(() => input.focus(), 100); + + const doSubmit = async () => { + const pin = input.value.trim(); + if (!pin) return; + + submit.disabled = true; + submit.textContent = 'Verifying...'; + + try { + const res = await fetch('/api/profiles/verify-launch-pin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }) + }); + const data = await res.json(); + + if (data.success) { + // Server session flag set by verify endpoint — consumed on next /api/profiles/current call + overlay.style.display = 'none'; + initApp(); // Now safe to load the full app + } else { + error.textContent = data.error || 'Invalid PIN'; + error.style.display = 'block'; + input.value = ''; + input.focus(); + // Shake animation + overlay.querySelector('.launch-pin-container').classList.add('shake'); + setTimeout(() => overlay.querySelector('.launch-pin-container').classList.remove('shake'), 500); + } + } catch (e) { + error.textContent = 'Connection error'; + error.style.display = 'block'; + } + + submit.disabled = false; + submit.textContent = 'Unlock'; + }; + + // Remove old listeners to prevent stacking + const newSubmit = submit.cloneNode(true); + submit.parentNode.replaceChild(newSubmit, submit); + newSubmit.addEventListener('click', doSubmit); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doSubmit(); + }); +} + +// ── Security Settings Helpers ────────────────────────────────────────── + +async function saveSecurityPin() { + const pin = document.getElementById('security-new-pin').value; + const confirm = document.getElementById('security-confirm-pin').value; + const msg = document.getElementById('security-pin-msg'); + + if (!pin || pin.length < 4) { + msg.textContent = 'PIN must be at least 4 characters'; + msg.style.display = 'block'; + msg.style.color = '#ff5252'; + return; + } + if (pin !== confirm) { + msg.textContent = 'PINs do not match'; + msg.style.display = 'block'; + msg.style.color = '#ff5252'; + return; + } + + try { + const res = await fetch('/api/profiles/1/set-pin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }) + }); + const data = await res.json(); + + if (data.success) { + msg.textContent = 'PIN saved! You can now enable the lock screen.'; + msg.style.color = '#4caf50'; + msg.style.display = 'block'; + + // Update UI — hide setup, show change, enable toggle + document.getElementById('security-pin-setup').style.display = 'none'; + document.getElementById('security-change-pin-section').style.display = 'block'; + document.getElementById('security-require-pin').disabled = false; + + // Clear inputs + document.getElementById('security-new-pin').value = ''; + document.getElementById('security-confirm-pin').value = ''; + } else { + msg.textContent = data.error || 'Failed to save PIN'; + msg.style.color = '#ff5252'; + msg.style.display = 'block'; + } + } catch (e) { + msg.textContent = 'Connection error'; + msg.style.color = '#ff5252'; + msg.style.display = 'block'; + } +} + +function handleSecurityPinToggle(checkbox) { + // If trying to enable but no PIN, show the setup section + if (checkbox.checked) { + const setupSection = document.getElementById('security-pin-setup'); + if (setupSection.style.display !== 'none' || checkbox.disabled) { + checkbox.checked = false; + setupSection.style.display = 'block'; + document.getElementById('security-new-pin').focus(); + return; + } + } + // Auto-save this setting + saveSettings(true); +} + +function showChangeSecurityPin() { + document.getElementById('security-pin-setup').style.display = 'block'; + document.getElementById('security-new-pin').focus(); +} + +// ── Forgot PIN Recovery ──────────────────────────────────────────────── + +function showForgotPinView() { + document.getElementById('launch-pin-entry').style.display = 'none'; + document.getElementById('launch-pin-recovery').style.display = 'block'; + document.getElementById('launch-recovery-input').value = ''; + document.getElementById('launch-recovery-error').style.display = 'none'; + setTimeout(() => document.getElementById('launch-recovery-input').focus(), 100); +} + +function showPinEntryView() { + document.getElementById('launch-pin-recovery').style.display = 'none'; + document.getElementById('launch-pin-entry').style.display = 'block'; + setTimeout(() => document.getElementById('launch-pin-input').focus(), 100); +} + +async function submitRecoveryCredential() { + const input = document.getElementById('launch-recovery-input'); + const error = document.getElementById('launch-recovery-error'); + const btn = document.getElementById('launch-recovery-submit'); + const credential = input.value.trim(); + + if (!credential) return; + + btn.disabled = true; + btn.textContent = 'Verifying...'; + error.style.display = 'none'; + + try { + const res = await fetch('/api/profiles/reset-pin-via-credential', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential }) + }); + const data = await res.json(); + + if (data.success) { + sessionStorage.setItem('soulsync_pin_ok', '1'); + document.getElementById('launch-pin-overlay').style.display = 'none'; + initApp(); + setTimeout(() => showToast('PIN cleared. You can set a new one in Settings → Advanced.', 'success'), 1000); + } else { + error.textContent = data.error || 'Credential not recognized'; + error.style.display = 'block'; + input.value = ''; + input.focus(); + document.getElementById('launch-pin-container').classList.add('shake'); + setTimeout(() => document.getElementById('launch-pin-container').classList.remove('shake'), 500); + } + } catch (e) { + error.textContent = 'Connection error'; + error.style.display = 'block'; + } + + btn.disabled = false; + btn.textContent = 'Verify & Reset PIN'; +} + function showProfilePicker(profiles, canCancel = false) { const overlay = document.getElementById('profile-picker-overlay'); const grid = document.getElementById('profile-picker-grid'); @@ -5706,6 +5915,30 @@ async function loadSettingsData() { console.error('Error loading log level:', error); } + // Load security settings + try { + const requirePin = settings.security?.require_pin_on_launch || false; + document.getElementById('security-require-pin').checked = requirePin; + + // Check if admin has a PIN set + const profilesRes = await fetch('/api/profiles'); + const profilesData = await profilesRes.json(); + const adminProfile = (profilesData.profiles || []).find(p => p.is_admin); + const adminHasPin = adminProfile?.has_pin || false; + + // Show/hide PIN setup vs change sections + document.getElementById('security-pin-setup').style.display = adminHasPin ? 'none' : 'block'; + document.getElementById('security-change-pin-section').style.display = adminHasPin ? 'block' : 'none'; + + // If no PIN, disable the toggle + if (!adminHasPin) { + document.getElementById('security-require-pin').checked = false; + document.getElementById('security-require-pin').disabled = true; + } + } catch (error) { + console.error('Error loading security settings:', error); + } + // Check dev mode status try { const devResponse = await fetch('/api/dev-mode'); @@ -6638,6 +6871,9 @@ async function saveSettings(quiet = false) { youtube: { cookies_browser: document.getElementById('youtube-cookies-browser').value, download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3, + }, + security: { + require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, } }; diff --git a/webui/static/style.css b/webui/static/style.css index baf6d653..907041c0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -37016,6 +37016,138 @@ body.helper-mode-active #dashboard-activity-feed:hover { word-break: break-word; } +/* ── Launch PIN Lock Screen ─────────────────────────────────────────── */ + +.launch-pin-overlay { + position: fixed; + inset: 0; + z-index: 1000000; + background: rgba(8, 8, 8, 0.97); + backdrop-filter: blur(24px); + display: flex; + align-items: center; + justify-content: center; +} + +.launch-pin-container { + text-align: center; + max-width: 320px; + width: 100%; + padding: 40px 32px; + background: rgba(20, 20, 20, 0.8); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 20px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5); +} + +.launch-pin-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.launch-pin-title { + font-size: 22px; + font-weight: 800; + color: #fff; + margin: 0 0 6px; + letter-spacing: -0.3px; +} + +.launch-pin-subtitle { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + margin: 0 0 24px; +} + +.launch-pin-input { + width: 100%; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1.5px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + color: #fff; + font-size: 18px; + font-weight: 600; + text-align: center; + letter-spacing: 4px; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; + font-family: inherit; +} + +.launch-pin-input:focus { + border-color: rgba(var(--accent-rgb), 0.5); +} + +.launch-pin-input::placeholder { + letter-spacing: normal; + font-weight: 400; + font-size: 14px; + color: rgba(255, 255, 255, 0.2); +} + +.launch-pin-submit { + width: 100%; + margin-top: 12px; + padding: 12px; + background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.7)); + border: none; + border-radius: 12px; + color: #fff; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.launch-pin-submit:hover { + filter: brightness(1.1); + box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.3); +} + +.launch-pin-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.launch-pin-error { + margin: 12px 0 0; + font-size: 13px; + color: #ff5252; + font-weight: 600; +} + +.launch-pin-forgot { + display: block; + margin: 16px auto 0; + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + font-size: 12px; + cursor: pointer; + font-family: inherit; + transition: color 0.15s ease; + padding: 4px 8px; +} + +.launch-pin-forgot:hover { + color: rgba(255, 255, 255, 0.6); +} + +.launch-pin-container.shake { + animation: launchPinShake 0.4s ease; +} + +@keyframes launchPinShake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-10px); } + 40% { transform: translateX(10px); } + 60% { transform: translateX(-6px); } + 80% { transform: translateX(6px); } +} + /* ── Profile Picker ─────────────────────────────────────────────── */ .profile-picker-overlay {