Login mode: let the admin set a member's login password (Manage Profiles)

Closes the gap where "Require login" was effectively admin-only: a member with no
password can't sign in and can't bootstrap one themselves (can't log in to reach
the setting). The set-password endpoint already allowed admin→anyone — this adds
the missing UI.

Each non-admin row in Manage Profiles gets a lock-icon button that opens an inline
form to set / change / remove that member's LOGIN password (separate from the
quick-switch PIN), with a confirm field + a hint explaining when it's used. Admin
rows don't get it (admin manages their own in Settings → Security, which keeps its
anti-lockout). textContent-only rendering, so a profile name can't inject markup.

Test: admin sets a member's password → the member can then authenticate
(verify_profile_password) and a wrong password fails; admin can clear it back to
no-login. 64 script-split integrity tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-11 19:24:31 -07:00
parent 29c8f11403
commit e046a2add4
2 changed files with 165 additions and 0 deletions

View file

@ -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

View file

@ -1924,6 +1924,18 @@ async function loadProfileManageList() {
actions.appendChild(editBtn); actions.appendChild(editBtn);
if (!p.is_admin) { 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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="16" r="1"/><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
actions.appendChild(pwBtn);
const delBtn = document.createElement('button'); const delBtn = document.createElement('button');
delBtn.className = 'profile-delete-btn'; delBtn.className = 'profile-delete-btn';
delBtn.dataset.id = p.id; 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 // Bind delete buttons
list.querySelectorAll('.profile-delete-btn').forEach(btn => { list.querySelectorAll('.profile-delete-btn').forEach(btn => {
btn.onclick = async () => { btn.onclick = async () => {
@ -1968,6 +1985,100 @@ async function loadProfileManageList() {
checkAdminPinRequired(); 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 = {}) { function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) {
const list = document.getElementById('profile-manage-list'); const list = document.getElementById('profile-manage-list');
// Remove any existing edit form // Remove any existing edit form