From f19db4ecce668abc54237cd4f407553b5b8724a6 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:15:36 -0700
Subject: [PATCH] Add launch PIN lock screen with credential-based recovery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Security:
- Toggle in Settings → Advanced: "Require PIN to access SoulSync"
- Full-screen lock overlay on every page load when enabled
- PIN validated server-side against admin profile (bcrypt hash)
- Inline PIN creation if admin has no PIN set, change PIN button if set
- One-time session flag: verify-launch-pin sets it, /profiles/current
consumes it — every page load re-requires PIN
Recovery:
- "Forgot PIN?" on lock screen switches to credential verification
- User pastes any configured API key/token/secret (Spotify, Tidal,
Plex, Jellyfin, Navidrome, ListenBrainz, AcoustID, Last.fm, Genius)
- Server checks against all 9 stored values — any match clears PIN
and disables lock, with toast guiding to Settings to set a new one
Profile switch integration:
- Entering PIN during profile switch also sets launch_pin_verified
flag, preventing double-PIN prompt on the subsequent page reload
Updated version modal and helper What's New with this feature.
---
web_server.py | 88 ++++++++++++++-
webui/index.html | 56 ++++++++++
webui/static/helper.js | 1 +
webui/static/script.js | 236 +++++++++++++++++++++++++++++++++++++++++
webui/static/style.css | 132 +++++++++++++++++++++++
5 files changed, 511 insertions(+), 2 deletions(-)
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 @@
+
+
+
+
+
+
🔒
+
SoulSync is Locked
+
Enter your PIN to continue
+
+
+
+
+
+
+
+
🔑
+
Verify Your Identity
+
Enter any API key, token, or secret you configured in SoulSync settings
+
+
+
+
+
+
+
+
+
+
+
🔒 Security
+
+
+
+
+
+
+
+
+
+
🔍 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 {