Login recovery (UI): Settings setup + "Forgot password?" on the login screen

- Settings → Security: a recovery-question picker (5 presets + Custom) + answer
  field + Save, posting to /api/profiles/1/set-recovery. handleRecoveryQuestionChange
  reveals the custom box.
- Login screen: a "Forgot password?" link opens a recovery view — enter username →
  fetch your question → answer + new password → reset → reload signed in. Reuses the
  launch-PIN overlay styling/structure (entry + recovery views).

All inert unless login mode is on, so a default/LAN install never sees any of it.
64 script-split integrity tests pass (every new handler resolves).
This commit is contained in:
BoulderBadgeDad 2026-06-10 22:28:07 -07:00
parent 613688a9ad
commit 5c80ee1010
2 changed files with 138 additions and 7 deletions

View file

@ -54,13 +54,33 @@
<!-- Login Screen (username/password mode) -->
<div id="login-overlay" class="launch-pin-overlay" style="display: none;">
<div class="launch-pin-container">
<div class="launch-pin-icon">🔐</div>
<h2 class="launch-pin-title">Sign in to SoulSync</h2>
<p class="launch-pin-subtitle">Enter your account name and password</p>
<input type="text" id="login-username" class="launch-pin-input" placeholder="Username" autocomplete="username" maxlength="40" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="login-password" class="launch-pin-input" placeholder="Password" autocomplete="current-password" maxlength="200" style="letter-spacing: normal;" onkeydown="if(event.key==='Enter') submitLogin()">
<button id="login-submit" class="launch-pin-submit" onclick="submitLogin()">Sign in</button>
<p id="login-error" class="launch-pin-error" style="display: none;"></p>
<!-- Sign-in view -->
<div id="login-entry">
<div class="launch-pin-icon">🔐</div>
<h2 class="launch-pin-title">Sign in to SoulSync</h2>
<p class="launch-pin-subtitle">Enter your account name and password</p>
<input type="text" id="login-username" class="launch-pin-input" placeholder="Username" autocomplete="username" maxlength="40" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="login-password" class="launch-pin-input" placeholder="Password" autocomplete="current-password" maxlength="200" style="letter-spacing: normal;" onkeydown="if(event.key==='Enter') submitLogin()">
<button id="login-submit" class="launch-pin-submit" onclick="submitLogin()">Sign in</button>
<p id="login-error" class="launch-pin-error" style="display: none;"></p>
<button class="launch-pin-forgot" onclick="showLoginRecovery()">Forgot password?</button>
</div>
<!-- Recovery view -->
<div id="login-recovery" style="display: none;">
<div class="launch-pin-icon">🔑</div>
<h2 class="launch-pin-title">Reset your password</h2>
<p class="launch-pin-subtitle">Answer your recovery question</p>
<input type="text" id="recovery-username" class="launch-pin-input" placeholder="Username" autocomplete="username" maxlength="40" style="margin-bottom: 8px; letter-spacing: normal;">
<button id="recovery-fetch-btn" class="launch-pin-submit" onclick="fetchRecoveryQuestion()">Continue</button>
<div id="recovery-answer-section" style="display: none;">
<p id="recovery-question-text" class="launch-pin-subtitle" style="margin-top: 12px; font-weight: 600;"></p>
<input type="text" id="recovery-answer" class="launch-pin-input" placeholder="Your answer" autocomplete="off" maxlength="120" style="margin-bottom: 8px; letter-spacing: normal;">
<input type="password" id="recovery-new-password" class="launch-pin-input" placeholder="New password (min 6)" autocomplete="new-password" maxlength="200" style="letter-spacing: normal;" onkeydown="if(event.key==='Enter') submitRecoveryReset()">
<button class="launch-pin-submit" onclick="submitRecoveryReset()">Reset password</button>
</div>
<p id="recovery-error" class="launch-pin-error" style="display: none;"></p>
<button class="launch-pin-forgot" onclick="showLoginEntry()">← Back to sign in</button>
</div>
</div>
</div>
@ -6040,6 +6060,26 @@
<p id="security-login-password-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
</div>
<div class="form-group">
<label for="security-recovery-question">Password recovery question:</label>
<div class="setting-help-text" style="margin-bottom: 8px;">
Lets you reset a forgotten login password by answering this. Recommended if you enable login. (You can set one per profile in <strong>Manage Profiles</strong> too.)
</div>
<select id="security-recovery-question" class="form-select" onchange="handleRecoveryQuestionChange()" style="margin-bottom: 6px;">
<option value="">— Select a question —</option>
<option>What was the name of your first pet?</option>
<option>What city were you born in?</option>
<option>What was your first concert?</option>
<option>What is your all-time favorite album?</option>
<option>What was the make of your first car?</option>
<option value="__custom__">Custom question…</option>
</select>
<input type="text" id="security-recovery-custom" placeholder="Type your own question" maxlength="120" style="display:none; margin-bottom: 6px;">
<input type="text" id="security-recovery-answer" placeholder="Your answer" maxlength="120" autocomplete="off" style="margin-bottom: 6px;">
<button class="auth-button" onclick="saveRecoveryQuestion()">Save Recovery Question</button>
<p id="security-recovery-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-login">

View file

@ -441,6 +441,65 @@ async function soulsyncLogout() {
window.location.reload();
}
function showLoginRecovery() {
const entry = document.getElementById('login-entry');
const rec = document.getElementById('login-recovery');
if (entry) entry.style.display = 'none';
if (rec) rec.style.display = 'block';
const u = document.getElementById('recovery-username');
const lu = document.getElementById('login-username');
if (u && lu && lu.value) u.value = lu.value;
const errEl = document.getElementById('recovery-error');
if (errEl) errEl.style.display = 'none';
}
function showLoginEntry() {
const entry = document.getElementById('login-entry');
const rec = document.getElementById('login-recovery');
if (rec) rec.style.display = 'none';
if (entry) entry.style.display = 'block';
}
async function fetchRecoveryQuestion() {
const username = (document.getElementById('recovery-username')?.value || '').trim();
const errEl = document.getElementById('recovery-error');
const section = document.getElementById('recovery-answer-section');
const qText = document.getElementById('recovery-question-text');
const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.style.display = 'block'; } };
if (errEl) errEl.style.display = 'none';
if (!username) { showErr('Enter your username'); return; }
try {
const res = await fetch('/api/auth/recovery-question?username=' + encodeURIComponent(username));
const data = await res.json();
if (data.success && data.question) {
if (qText) qText.textContent = data.question;
if (section) section.style.display = 'block';
} else {
showErr('No recovery question is set for that account.');
}
} catch (e) { showErr('Connection error'); }
}
async function submitRecoveryReset() {
const username = (document.getElementById('recovery-username')?.value || '').trim();
const answer = document.getElementById('recovery-answer')?.value || '';
const newPassword = document.getElementById('recovery-new-password')?.value || '';
const errEl = document.getElementById('recovery-error');
const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.style.display = 'block'; } };
if (errEl) errEl.style.display = 'none';
if (!answer || !newPassword) { showErr('Enter your answer and a new password'); return; }
if (newPassword.length < 6) { showErr('New password must be at least 6 characters'); return; }
try {
const res = await fetch('/api/auth/recovery-reset', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, answer, new_password: newPassword }),
});
const data = await res.json();
if (data.success) { window.location.reload(); }
else { showErr(res.status === 429 ? 'Too many attempts — wait a moment.' : (data.error || 'Reset failed')); }
} catch (e) { showErr('Connection error'); }
}
// ── Launch PIN Lock Screen ─────────────────────────────────────────────
function showLaunchPinScreen() {
@ -527,6 +586,38 @@ async function saveLoginPassword() {
} catch (e) { show('Connection error', false); }
}
function handleRecoveryQuestionChange() {
const sel = document.getElementById('security-recovery-question');
const custom = document.getElementById('security-recovery-custom');
if (sel && custom) custom.style.display = (sel.value === '__custom__') ? 'block' : 'none';
}
async function saveRecoveryQuestion() {
const sel = document.getElementById('security-recovery-question');
const custom = document.getElementById('security-recovery-custom');
const answer = document.getElementById('security-recovery-answer')?.value || '';
const msg = document.getElementById('security-recovery-msg');
const show = (text, ok) => {
if (!msg) return;
msg.textContent = text;
msg.style.color = ok ? '#4caf50' : '#ff5252';
msg.style.display = 'block';
};
let question = sel?.value || '';
if (question === '__custom__') question = (custom?.value || '').trim();
if (!question) { show('Pick or type a question', false); return; }
if (!answer.trim()) { show('Enter an answer', false); return; }
try {
const res = await fetch('/api/profiles/1/set-recovery', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, answer }),
});
const data = await res.json();
if (data.success) { show('Recovery question saved', true); const a = document.getElementById('security-recovery-answer'); if (a) a.value = ''; }
else show(data.error || 'Failed to save', false);
} catch (e) { show('Connection error', false); }
}
async function saveSecurityPin() {
const pin = document.getElementById('security-new-pin').value;
const confirm = document.getElementById('security-confirm-pin').value;