diff --git a/tests/test_member_login_password.py b/tests/test_member_login_password.py new file mode 100644 index 00000000..f6248cdf --- /dev/null +++ b/tests/test_member_login_password.py @@ -0,0 +1,54 @@ +"""Admin sets a member's LOGIN password (the gap behind 'non-admins can't log in +when Require Login is on'). The endpoint already allowed admin→anyone; this locks +that the round-trip actually lets the member authenticate.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-memberpw-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'm.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _make_member(db, pid=77): + conn = db._get_connection() + try: + conn.execute("INSERT OR REPLACE INTO profiles (id, name, is_admin) VALUES (?,?,0)", (pid, 'Member')) + conn.commit() + finally: + conn.close() + + +def test_admin_sets_member_password_then_member_can_authenticate(client): + db = web_server.get_database() + _make_member(db) + assert db.verify_profile_password(77, 'secret123') is False # no password → can't log in + + r = client.post('/api/profiles/77/set-password', json={'password': 'secret123'}) + assert r.status_code == 200 + body = r.get_json() + assert body['success'] is True and body['has_password'] is True + + assert db.verify_profile_password(77, 'secret123') is True # member can now authenticate + assert db.verify_profile_password(77, 'wrong') is False + + +def test_admin_can_clear_member_password(client): + db = web_server.get_database() + _make_member(db, pid=78) + client.post('/api/profiles/78/set-password', json={'password': 'pw12345'}) + assert db.verify_profile_password(78, 'pw12345') is True + r = client.post('/api/profiles/78/set-password', json={'password': ''}) + assert r.status_code == 200 + assert db.verify_profile_password(78, 'pw12345') is False # cleared → no login again diff --git a/webui/static/init.js b/webui/static/init.js index 4da3795f..0c552aea 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -1924,6 +1924,18 @@ async function loadProfileManageList() { actions.appendChild(editBtn); if (!p.is_admin) { + // Set/change the LOGIN password (separate from the quick-switch PIN; + // used when "Require login" is on). A member with no password can't + // sign in and can't self-bootstrap one, so the admin sets it here. + const pwBtn = document.createElement('button'); + pwBtn.className = 'profile-password-btn' + (p.has_password ? ' has-password' : ''); + pwBtn.dataset.id = p.id; + pwBtn.dataset.name = p.name; + pwBtn.dataset.hasPassword = p.has_password ? '1' : '0'; + pwBtn.title = p.has_password ? 'Change login password' : 'Set login password (for Require Login mode)'; + pwBtn.innerHTML = ''; + actions.appendChild(pwBtn); + const delBtn = document.createElement('button'); delBtn.className = 'profile-delete-btn'; delBtn.dataset.id = p.id; @@ -1948,6 +1960,11 @@ async function loadProfileManageList() { }; }); + // Bind set-login-password buttons + list.querySelectorAll('.profile-password-btn').forEach(btn => { + btn.onclick = () => showProfilePasswordForm(btn.dataset.id, btn.dataset.name, btn.dataset.hasPassword === '1'); + }); + // Bind delete buttons list.querySelectorAll('.profile-delete-btn').forEach(btn => { btn.onclick = async () => { @@ -1968,6 +1985,100 @@ async function loadProfileManageList() { checkAdminPinRequired(); } +function showProfilePasswordForm(profileId, name, hasPassword) { + const list = document.getElementById('profile-manage-list'); + // One inline form at a time — drop any edit/password form already open. + ['profile-password-form', 'profile-edit-form'].forEach(id => { + const el = document.getElementById(id); if (el) el.remove(); + }); + + const form = document.createElement('div'); + form.id = 'profile-password-form'; + form.className = 'profile-edit-form'; + + const title = document.createElement('div'); + title.style.cssText = 'font-weight:600;margin-bottom:4px;'; + title.textContent = 'Login password — ' + name; // textContent = XSS-safe + form.appendChild(title); + + const hint = document.createElement('div'); + hint.style.cssText = 'font-size:0.8em;color:rgba(255,255,255,0.5);margin-bottom:8px;line-height:1.4;'; + hint.textContent = 'Used when "Require login" is on (separate from the quick-switch PIN). ' + + (hasPassword ? 'This profile has a password set.' + : "This profile has no password yet — it can't sign in until you set one."); + form.appendChild(hint); + + const pw = document.createElement('input'); + pw.type = 'password'; pw.className = 'profile-input'; + pw.placeholder = 'New password'; pw.autocomplete = 'new-password'; + const confirm = document.createElement('input'); + confirm.type = 'password'; confirm.className = 'profile-input'; + confirm.placeholder = 'Confirm password'; confirm.autocomplete = 'new-password'; + form.appendChild(pw); form.appendChild(confirm); + + const msg = document.createElement('div'); + msg.style.cssText = 'font-size:0.8em;margin:6px 0;display:none;'; + form.appendChild(msg); + const showMsg = (t, ok) => { + msg.textContent = t; msg.style.color = ok ? '#10b981' : '#ef4444'; msg.style.display = 'block'; + }; + + const post = async (password, okMsg, okType) => { + const res = await fetch('/api/profiles/' + encodeURIComponent(profileId) + '/set-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (data.success) { + form.remove(); + loadProfileManageList(); + if (typeof showToast === 'function') showToast(okMsg, okType); + return true; + } + showMsg(data.error || 'Failed to update password', false); + return false; + }; + + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;'; + + const saveBtn = document.createElement('button'); + saveBtn.className = 'btn btn--primary'; + saveBtn.textContent = 'Save password'; + saveBtn.onclick = async () => { + const p1 = pw.value, p2 = confirm.value; + if (!p1 || !p1.trim()) { showMsg('Enter a password', false); return; } + if (p1.length < 4) { showMsg('Use at least 4 characters', false); return; } + if (p1 !== p2) { showMsg("Passwords don't match", false); return; } + saveBtn.disabled = true; + try { if (!await post(p1, 'Login password set for ' + name, 'success')) saveBtn.disabled = false; } + catch (e) { showMsg('Connection error', false); saveBtn.disabled = false; } + }; + btnRow.appendChild(saveBtn); + + if (hasPassword) { + const clearBtn = document.createElement('button'); + clearBtn.className = 'btn'; + clearBtn.textContent = 'Remove password'; + clearBtn.onclick = async () => { + clearBtn.disabled = true; + try { if (!await post('', 'Login password removed', 'info')) clearBtn.disabled = false; } + catch (e) { showMsg('Connection error', false); clearBtn.disabled = false; } + }; + btnRow.appendChild(clearBtn); + } + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => form.remove(); + btnRow.appendChild(cancelBtn); + + form.appendChild(btnRow); + list.appendChild(form); + pw.focus(); +} + function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) { const list = document.getElementById('profile-manage-list'); // Remove any existing edit form