Settings: reorganize Security into clear groups with visible prerequisites
The security section had grown into a flat pile of toggles with hidden dependencies. Regrouped into three labelled cards so it reads top-to-bottom: - 🔑 Lock with a PIN — set PIN (Step 1) → Require PIN - 👤 User accounts (login) — Step 1 admin password → Step 2 recovery question → Step 3 Require login. The Step 3 toggle is now visually LOCKED (greyed + disabled + "set the admin password first" hint) until an admin password exists, so the anti-lockout rule is obvious instead of surfacing as a 400 on save. It unlocks the moment the password is saved. - 🌐 Reverse proxy & remote access — the proxy toggle, with the auth-proxy header nested under it (indented), plus WebSocket origins. - get_all_profiles/get_profile now expose has_password + has_recovery so the UI can reflect setup state; updateRequireLoginGate() drives the lock. - New .security-subgroup/.security-subhead/.security-nested/.security-locked CSS. All IDs + handlers preserved. Inert unless used; default install unaffected. 64 script-split integrity tests pass.
This commit is contained in:
parent
5c80ee1010
commit
2bb9bc1357
5 changed files with 163 additions and 79 deletions
|
|
@ -5246,6 +5246,8 @@ class MusicDatabase:
|
|||
'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
|
||||
'is_admin': bool(row['is_admin']),
|
||||
'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,
|
||||
'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,
|
||||
|
|
@ -5276,6 +5278,8 @@ class MusicDatabase:
|
|||
'avatar_url': row['avatar_url'] if 'avatar_url' in columns else None,
|
||||
'is_admin': bool(row['is_admin']),
|
||||
'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,
|
||||
'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,
|
||||
|
|
|
|||
175
webui/index.html
175
webui/index.html
|
|
@ -5993,100 +5993,119 @@
|
|||
|
||||
</div><!-- end Library Preferences body -->
|
||||
|
||||
<!-- Security Settings -->
|
||||
<!-- Security & Access 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>
|
||||
<h3>🔒 Security & Access</h3>
|
||||
<div class="setting-help-text" style="margin-bottom: 18px;">
|
||||
By default, anyone who can reach SoulSync on your network can use it. Protect access below with a simple shared <strong>PIN</strong>, or full <strong>user accounts</strong> with passwords — you only need one. The last group is just for exposing SoulSync over the internet.
|
||||
</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.
|
||||
<!-- ── Method A: PIN ── -->
|
||||
<div class="security-subgroup">
|
||||
<h4 class="security-subhead">🔑 Lock with a PIN <span class="security-subhead-note">simple · one shared PIN</span></h4>
|
||||
|
||||
<div class="form-group" id="security-pin-setup" style="display: none;">
|
||||
<label>Step 1 — Set admin PIN:</label>
|
||||
<div class="setting-help-text" style="margin-bottom: 8px;">
|
||||
Set a PIN before you can turn on 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">
|
||||
A lock screen appears on every page load, verified against the admin PIN. Closing the 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>
|
||||
|
||||
<div class="form-group" id="security-change-pin-section" style="display: none;">
|
||||
<button class="auth-button" onclick="showChangeSecurityPin()">Change PIN</button>
|
||||
</div>
|
||||
<!-- ── Method B: Login accounts ── -->
|
||||
<div class="security-subgroup">
|
||||
<h4 class="security-subhead">👤 User accounts (login) <span class="security-subhead-note">per-person · best for public access</span></h4>
|
||||
<div class="setting-help-text" style="margin-bottom: 12px;">
|
||||
Everyone signs in with their account name + password. Turning this on <strong>replaces</strong> the PIN and the profile picker. Set passwords for other people in <strong>Manage Profiles</strong>.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="security-cors-origins">Allowed WebSocket Origins:</label>
|
||||
<textarea id="security-cors-origins" rows="3" placeholder="https://soulsync.example.com http://192.168.1.5:8888" style="width: 100%; font-family: monospace; font-size: 12px;"></textarea>
|
||||
<div class="setting-help-text">
|
||||
Origins (full URL, no trailing slash) allowed to open WebSocket connections to this instance — one per line, or comma-separated. Leave empty for same-origin only (the secure default; works for direct access and most reverse-proxy setups). Add your public domain here if you reach SoulSync via a reverse proxy or custom domain and the WebSocket fails to connect. Use <code>*</code> on its own line to allow any origin (insecure — only do this if you understand why you need it).
|
||||
<div class="form-group">
|
||||
<label>Step 1 — Admin login password:</label>
|
||||
<input type="password" id="security-login-password" placeholder="Set admin login password" maxlength="200" autocomplete="new-password" style="margin: 6px 0;">
|
||||
<button class="auth-button" onclick="saveLoginPassword()">Save Password</button>
|
||||
<p id="security-login-password-msg" class="setting-help-text" style="margin-top: 6px; display: none;"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Step 2 <span style="opacity:0.6">(recommended)</span> — Password recovery question:</label>
|
||||
<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>
|
||||
<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" id="security-login-toggle-wrap">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="security-require-login">
|
||||
<span>Step 3 — Require login (username + password)</span>
|
||||
</label>
|
||||
<div class="setting-help-text" id="security-require-login-help">
|
||||
Replaces the profile picker + PIN with a sign-in screen. Best for instances exposed to the internet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="security-trust-proxy">
|
||||
<span>Behind a reverse proxy</span>
|
||||
</label>
|
||||
<div class="setting-help-text">
|
||||
Enable only if SoulSync runs behind nginx / Caddy / Traefik that terminates HTTPS. Trusts the proxy's <code>X-Forwarded-*</code> headers, marks the session cookie HTTPS-only, and adds security headers. <strong>Leave OFF for direct or LAN access over http://</strong> — turning it on would make the session cookie HTTPS-only and break plain-HTTP access. <strong>Takes effect after a restart.</strong> See <code>Support/REVERSE-PROXY.md</code>.
|
||||
<!-- ── Reverse proxy & remote access ── -->
|
||||
<div class="security-subgroup">
|
||||
<h4 class="security-subhead">🌐 Reverse proxy & remote access</h4>
|
||||
<div class="setting-help-text" style="margin-bottom: 12px;">
|
||||
Only needed if you expose SoulSync to the internet behind nginx / Caddy / Traefik. See <code>Support/REVERSE-PROXY.md</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="security-auth-proxy-header">Auth proxy user header <span style="opacity:0.6">(optional)</span>:</label>
|
||||
<input type="text" id="security-auth-proxy-header" placeholder="Remote-User" autocomplete="off" style="width: 100%; font-family: monospace; font-size: 12px;">
|
||||
<div class="setting-help-text">
|
||||
If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in <em>in front of</em> SoulSync, enter the header it sets (e.g. <code>Remote-User</code>) and SoulSync will skip the launch PIN for already-authenticated requests. <strong>Only set this behind a proxy that strips any client-supplied copy of the header</strong> — otherwise it can be spoofed. Leave blank to disable (the default).
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="security-trust-proxy">
|
||||
<span>Behind a reverse proxy</span>
|
||||
</label>
|
||||
<div class="setting-help-text">
|
||||
Trusts the proxy's <code>X-Forwarded-*</code> headers, marks the session cookie HTTPS-only, and adds security headers. <strong>Leave OFF for direct / LAN access over http://</strong> — it would otherwise break plain-HTTP login. <strong>Takes effect after a restart.</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:18px 0 14px;">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Login password (admin account):</label>
|
||||
<div class="setting-help-text" style="margin-bottom: 8px;">
|
||||
Set a password for the admin account, then turn on "Require login" below. Your username is your profile name. Set passwords for other profiles in <strong>Manage Profiles</strong>.
|
||||
<div class="form-group security-nested">
|
||||
<label for="security-auth-proxy-header">↳ Auth proxy user header <span style="opacity:0.6">(optional)</span>:</label>
|
||||
<input type="text" id="security-auth-proxy-header" placeholder="Remote-User" autocomplete="off" style="width: 100%; font-family: monospace; font-size: 12px;">
|
||||
<div class="setting-help-text">
|
||||
If an auth proxy (Authelia / Authentik / oauth2-proxy) logs users in in front of SoulSync, enter the header it sets and SoulSync trusts it. <strong>Only set this behind a proxy that strips any client-supplied copy of the header.</strong> Blank = off.
|
||||
</div>
|
||||
</div>
|
||||
<input type="password" id="security-login-password" placeholder="Set admin login password" maxlength="200" autocomplete="new-password" style="margin-bottom: 6px;">
|
||||
<button class="auth-button" onclick="saveLoginPassword()">Save Password</button>
|
||||
<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">
|
||||
<span>Require login (username + password)</span>
|
||||
</label>
|
||||
<div class="setting-help-text">
|
||||
When enabled, a sign-in screen replaces the profile picker + launch PIN — everyone signs in with their account name + password. <strong>Set the admin password above first</strong> (you can't enable this without one, to avoid locking yourself out). Best for instances exposed to the internet.
|
||||
<div class="form-group">
|
||||
<label for="security-cors-origins">Allowed WebSocket Origins:</label>
|
||||
<textarea id="security-cors-origins" rows="3" placeholder="https://soulsync.example.com http://192.168.1.5:8888" style="width: 100%; font-family: monospace; font-size: 12px;"></textarea>
|
||||
<div class="setting-help-text">
|
||||
Origins (full URL, no trailing slash) allowed to open WebSocket connections — one per line or comma-separated. Empty = same-origin only (the secure default; fine for direct access and most reverse-proxy setups). Add your public domain if live updates fail behind a proxy. <code>*</code> on its own line allows any origin (insecure).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -581,11 +581,33 @@ async function saveLoginPassword() {
|
|||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { show('Admin login password saved', true); if (input) input.value = ''; }
|
||||
if (data.success) {
|
||||
show('Admin login password saved', true);
|
||||
if (input) input.value = '';
|
||||
updateRequireLoginGate(true); // Step 1 done → unlock Step 3
|
||||
}
|
||||
else show(data.error || 'Failed to save password', false);
|
||||
} catch (e) { show('Connection error', false); }
|
||||
}
|
||||
|
||||
// Lock/unlock the "Require login" toggle based on whether the admin has a
|
||||
// password — makes the prerequisite (anti-lockout) visible instead of a
|
||||
// surprise 400 on save.
|
||||
function updateRequireLoginGate(hasPassword) {
|
||||
const toggle = document.getElementById('security-require-login');
|
||||
const wrap = document.getElementById('security-login-toggle-wrap');
|
||||
const help = document.getElementById('security-require-login-help');
|
||||
if (!toggle) return;
|
||||
toggle.disabled = !hasPassword;
|
||||
if (!hasPassword) toggle.checked = false;
|
||||
if (wrap) wrap.classList.toggle('security-locked', !hasPassword);
|
||||
if (help) {
|
||||
help.innerHTML = hasPassword
|
||||
? 'Replaces the profile picker + PIN with a sign-in screen. Best for instances exposed to the internet.'
|
||||
: '🔒 Set the admin password in <strong>Step 1</strong> first — then you can turn this on.';
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecoveryQuestionChange() {
|
||||
const sel = document.getElementById('security-recovery-question');
|
||||
const custom = document.getElementById('security-recovery-custom');
|
||||
|
|
|
|||
|
|
@ -1421,6 +1421,10 @@ async function loadSettingsData() {
|
|||
document.getElementById('security-require-pin').checked = false;
|
||||
document.getElementById('security-require-pin').disabled = true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
} catch (error) {
|
||||
console.error('Error loading security settings:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67892,3 +67892,38 @@ body.em-scroll-lock { overflow: hidden; }
|
|||
padding: 7px 11px; color: #fff; font-size: 0.82rem; width: 150px;
|
||||
}
|
||||
.ma-token-input:focus { outline: none; border-color: var(--ma-brand); }
|
||||
|
||||
/* ── Security settings: grouped sub-sections + dependency visuals ── */
|
||||
.security-subgroup {
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px 4px;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.security-subhead {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.security-subhead-note {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
opacity: 0.55;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.security-nested {
|
||||
margin-left: 14px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.security-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.security-locked .toggle-label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue