Security UI: show saved login password / recovery question state

After saving a password or recovery question, a refresh made the section look
unset (passwords are never echoed back to the browser), so it seemed like you had
to redo it. Now the saved state is reflected:

- "✓ A login password is set" appears when the admin has a password; the field
  becomes "Enter a new password to change it".
- "✓ Recovery question saved: <question>" appears, the saved question is pre-
  selected (preset or custom), and the answer field becomes "Enter a new answer to
  change it".
- Shown both on load (applyLoginSavedState from /api/profiles, which now includes
  recovery_question — not secret, already shown on the sign-in screen) and
  immediately after saving.

64 integrity tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-10 22:46:15 -07:00
parent 09d4bbc530
commit fece771dd0
5 changed files with 61 additions and 2 deletions

View file

@ -5248,6 +5248,7 @@ class MusicDatabase:
'has_pin': row['pin_hash'] is not None,
'has_password': row['password_hash'] is not None if 'password_hash' in columns else False,
'has_recovery': row['recovery_answer_hash'] is not None if 'recovery_answer_hash' in columns else False,
'recovery_question': row['recovery_question'] if 'recovery_question' in columns else None,
'home_page': row['home_page'] if 'home_page' in columns else None,
'allowed_pages': json.loads(ap_raw) if ap_raw else None,
'can_download': bool(row['can_download']) if 'can_download' in columns else True,
@ -5280,6 +5281,7 @@ class MusicDatabase:
'has_pin': row['pin_hash'] is not None,
'has_password': row['password_hash'] is not None if 'password_hash' in columns else False,
'has_recovery': row['recovery_answer_hash'] is not None if 'recovery_answer_hash' in columns else False,
'recovery_question': row['recovery_question'] if 'recovery_question' in columns else None,
'home_page': row['home_page'] if 'home_page' in columns else None,
'allowed_pages': json.loads(ap_raw) if ap_raw else None,
'can_download': bool(row['can_download']) if 'can_download' in columns else True,

View file

@ -6040,6 +6040,7 @@
<div class="form-group">
<label>Step 1 — Admin login password:</label>
<div class="security-saved-status" id="security-login-password-status" style="display:none;">✓ A login password is set</div>
<input type="password" id="security-login-password" placeholder="Enter password (min 6)" maxlength="200" autocomplete="new-password" style="margin: 6px 0;">
<input type="password" id="security-login-password-confirm" placeholder="Confirm password" maxlength="200" autocomplete="new-password" style="margin-bottom: 6px;">
<button class="auth-button" onclick="saveLoginPassword()">Save Password</button>
@ -6051,6 +6052,7 @@
<div class="setting-help-text" style="margin-bottom: 8px;">
So you can reset a forgotten password by answering it on the sign-in screen.
</div>
<div class="security-saved-status" id="security-recovery-status" style="display:none;"></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>

View file

@ -588,9 +588,11 @@ async function saveLoginPassword() {
const data = await res.json();
if (data.success) {
show('Admin login password saved', true);
if (input) input.value = '';
if (input) { input.value = ''; input.placeholder = 'Enter a new password to change it'; }
if (confirmInput) confirmInput.value = '';
updateRequireLoginGate(true); // Step 1 done → unlock Step 3
const st = document.getElementById('security-login-password-status');
if (st) st.style.display = 'block';
}
else show(data.error || 'Failed to save password', false);
} catch (e) { show('Connection error', false); }
@ -614,6 +616,45 @@ function updateRequireLoginGate(hasPassword) {
}
}
// Reflect already-saved login credentials. Passwords are never sent to the
// browser, so instead of an empty field (which looks unset after a refresh) we
// show that one is set and pre-fill the saved recovery question.
function applyLoginSavedState(profile) {
const hasPassword = profile?.has_password || false;
const hasRecovery = profile?.has_recovery || false;
const question = profile?.recovery_question || '';
const pwStatus = document.getElementById('security-login-password-status');
const pwField = document.getElementById('security-login-password');
const pwConfirm = document.getElementById('security-login-password-confirm');
if (pwStatus) pwStatus.style.display = hasPassword ? 'block' : 'none';
if (hasPassword) {
if (pwField) pwField.placeholder = 'Enter a new password to change it';
if (pwConfirm) pwConfirm.placeholder = 'Confirm new password';
}
const recStatus = document.getElementById('security-recovery-status');
const recSel = document.getElementById('security-recovery-question');
const recCustom = document.getElementById('security-recovery-custom');
const recAnswer = document.getElementById('security-recovery-answer');
if (recStatus) {
recStatus.style.display = hasRecovery ? 'block' : 'none';
recStatus.textContent = hasRecovery
? ('✓ Recovery question saved' + (question ? ': “' + question + '”' : ''))
: '';
}
if (hasRecovery) {
if (recSel && question) {
recSel.value = question; // preset options default value = their text
if (recSel.value !== question) { // not a preset → custom question
recSel.value = '__custom__';
if (recCustom) { recCustom.style.display = 'block'; recCustom.value = question; }
}
}
if (recAnswer) recAnswer.placeholder = 'Enter a new answer to change it';
}
}
function handleRecoveryQuestionChange() {
const sel = document.getElementById('security-recovery-question');
const custom = document.getElementById('security-recovery-custom');
@ -641,7 +682,13 @@ async function saveRecoveryQuestion() {
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 = ''; }
if (data.success) {
show('Recovery question saved', true);
const a = document.getElementById('security-recovery-answer');
if (a) { a.value = ''; a.placeholder = 'Enter a new answer to change it'; }
const rst = document.getElementById('security-recovery-status');
if (rst) { rst.style.display = 'block'; rst.textContent = '✓ Recovery question saved: “' + question + '”'; }
}
else show(data.error || 'Failed to save', false);
} catch (e) { show('Connection error', false); }
}

View file

@ -1425,6 +1425,8 @@ async function loadSettingsData() {
// Login: the "Require login" toggle is gated on an admin password —
// visually locked until Step 1 is done (anti-lockout, made obvious).
updateRequireLoginGate(adminProfile?.has_password || false);
// Show already-saved password/recovery state (vs looking unset).
applyLoginSavedState(adminProfile);
} catch (error) {
console.error('Error loading security settings:', error);
}

View file

@ -67927,3 +67927,9 @@ body.em-scroll-lock { overflow: hidden; }
.security-locked .toggle-label {
cursor: not-allowed;
}
.security-saved-status {
font-size: 12px;
color: #4caf50;
font-weight: 500;
margin-bottom: 6px;
}