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:
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue