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:
parent
59e258a922
commit
f19db4ecce
5 changed files with 511 additions and 2 deletions
|
|
@ -4499,7 +4499,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -18888,6 +18888,17 @@ def get_version_info():
|
||||||
"• Deezer enrichment worker caches API calls through metadata cache",
|
"• Deezer enrichment worker caches API calls through metadata cache",
|
||||||
"• Per-source quality fallback toggles for streaming download sources"
|
"• 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
|
return jsonify({'success': False, 'error': 'Invalid PIN'}), 401
|
||||||
|
|
||||||
session['profile_id'] = profile_id
|
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})
|
return jsonify({'success': True, 'profile': profile})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -33181,7 +33196,76 @@ def get_current_profile():
|
||||||
session.pop('profile_id', None)
|
session.pop('profile_id', None)
|
||||||
return jsonify({'success': False, 'error': 'Profile not found'}), 200
|
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:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,32 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 -->
|
<!-- 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">
|
||||||
|
|
@ -4786,6 +4812,36 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Discovery Settings -->
|
||||||
<div class="settings-group" data-stg="advanced">
|
<div class="settings-group" data-stg="advanced">
|
||||||
<h3>🔍 Discovery Pool Settings</h3>
|
<h3>🔍 Discovery Pool Settings</h3>
|
||||||
|
|
|
||||||
|
|
@ -3403,6 +3403,7 @@ function closeHelperSearch() {
|
||||||
const WHATS_NEW = {
|
const WHATS_NEW = {
|
||||||
'2.1': [
|
'2.1': [
|
||||||
// Newest features first
|
// 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: '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: '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' },
|
{ 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' },
|
||||||
|
|
|
||||||
|
|
@ -966,6 +966,13 @@ async function initProfileSystem() {
|
||||||
if (currentData.success && currentData.profile) {
|
if (currentData.success && currentData.profile) {
|
||||||
currentProfile = currentData.profile;
|
currentProfile = currentData.profile;
|
||||||
updateProfileIndicator();
|
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
|
return true; // Profile already selected, skip picker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -983,6 +990,15 @@ async function initProfileSystem() {
|
||||||
if (profiles.length === 1) {
|
if (profiles.length === 1) {
|
||||||
// Only one profile — always auto-select (PIN only matters with multiple profiles)
|
// Only one profile — always auto-select (PIN only matters with multiple profiles)
|
||||||
await selectProfile(profiles[0].id);
|
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;
|
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) {
|
function showProfilePicker(profiles, canCancel = false) {
|
||||||
const overlay = document.getElementById('profile-picker-overlay');
|
const overlay = document.getElementById('profile-picker-overlay');
|
||||||
const grid = document.getElementById('profile-picker-grid');
|
const grid = document.getElementById('profile-picker-grid');
|
||||||
|
|
@ -5706,6 +5915,30 @@ async function loadSettingsData() {
|
||||||
console.error('Error loading log level:', error);
|
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
|
// Check dev mode status
|
||||||
try {
|
try {
|
||||||
const devResponse = await fetch('/api/dev-mode');
|
const devResponse = await fetch('/api/dev-mode');
|
||||||
|
|
@ -6638,6 +6871,9 @@ async function saveSettings(quiet = false) {
|
||||||
youtube: {
|
youtube: {
|
||||||
cookies_browser: document.getElementById('youtube-cookies-browser').value,
|
cookies_browser: document.getElementById('youtube-cookies-browser').value,
|
||||||
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
|
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37016,6 +37016,138 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
word-break: break-word;
|
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 ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.profile-picker-overlay {
|
.profile-picker-overlay {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue