Add launch PIN lock screen with credential-based recovery

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.
This commit is contained in:
Broque Thomas 2026-03-26 13:15:36 -07:00
parent 59e258a922
commit f19db4ecce
5 changed files with 511 additions and 2 deletions

View file

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

View file

@ -11,6 +11,32 @@
</head>
<body>
<!-- Launch PIN Lock Screen -->
<div id="launch-pin-overlay" class="launch-pin-overlay" style="display: none;">
<div class="launch-pin-container" id="launch-pin-container">
<!-- PIN Entry (default view) -->
<div id="launch-pin-entry">
<div class="launch-pin-icon">🔒</div>
<h2 class="launch-pin-title">SoulSync is Locked</h2>
<p class="launch-pin-subtitle">Enter your PIN to continue</p>
<input type="password" id="launch-pin-input" class="launch-pin-input" maxlength="20" placeholder="PIN" autocomplete="off">
<button id="launch-pin-submit" class="launch-pin-submit">Unlock</button>
<p id="launch-pin-error" class="launch-pin-error" style="display: none;"></p>
<button class="launch-pin-forgot" onclick="showForgotPinView()">Forgot PIN?</button>
</div>
<!-- Forgot PIN (recovery view) -->
<div id="launch-pin-recovery" style="display: none;">
<div class="launch-pin-icon">🔑</div>
<h2 class="launch-pin-title">Verify Your Identity</h2>
<p class="launch-pin-subtitle">Enter any API key, token, or secret you configured in SoulSync settings</p>
<input type="password" id="launch-recovery-input" class="launch-pin-input" maxlength="200" placeholder="Paste any API credential..." autocomplete="off" style="letter-spacing: 1px;">
<button id="launch-recovery-submit" class="launch-pin-submit" onclick="submitRecoveryCredential()">Verify & Reset PIN</button>
<p id="launch-recovery-error" class="launch-pin-error" style="display: none;"></p>
<button class="launch-pin-forgot" onclick="showPinEntryView()">← Back to PIN</button>
</div>
</div>
</div>
<!-- Profile Picker Overlay -->
<div id="profile-picker-overlay" class="profile-picker-overlay" style="display: none;">
<div class="profile-picker-container">
@ -4786,6 +4812,36 @@
</div>
</div>
<!-- Security Settings -->
<div class="settings-group" data-stg="advanced">
<h3>🔒 Security</h3>
<div class="form-group" id="security-pin-setup" style="display: none;">
<label>Set Admin PIN:</label>
<div class="setting-help-text" style="margin-bottom: 8px;">
You need to set a PIN before enabling the lock screen.
</div>
<input type="password" id="security-new-pin" placeholder="Enter PIN" maxlength="20" autocomplete="off" style="margin-bottom: 6px;">
<input type="password" id="security-confirm-pin" placeholder="Confirm PIN" maxlength="20" autocomplete="off" style="margin-bottom: 6px;">
<button class="auth-button" id="security-save-pin-btn" onclick="saveSecurityPin()">Save PIN</button>
<p id="security-pin-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-pin" onchange="handleSecurityPinToggle(this)">
<span>Require PIN to access SoulSync</span>
</label>
<div class="setting-help-text">
When enabled, a lock screen appears on every page load. PIN is verified against the admin account. Closing the browser tab requires re-entry.
</div>
</div>
<div class="form-group" id="security-change-pin-section" style="display: none;">
<button class="auth-button" onclick="showChangeSecurityPin()">Change PIN</button>
</div>
</div>
<!-- Discovery Settings -->
<div class="settings-group" data-stg="advanced">
<h3>🔍 Discovery Pool Settings</h3>

View file

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

View file

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

View file

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