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:
parent
29c8f11403
commit
e046a2add4
2 changed files with 165 additions and 0 deletions
54
tests/test_member_login_password.py
Normal file
54
tests/test_member_login_password.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue