Add household admin roles: invite gating, promote/demote, removal, passwords

Every household now always has exactly one or more admins. Admins can
invite new members (previously open to any parent -- now gated), promote/
demote other admins, remove parents, and directly set another parent's
password (no email infra exists for a reset-link flow, so this is a
direct admin-sets-the-value action). Every parent can change their own
password with current-password confirmation. A sole admin can't remove
themselves or demote until they promote someone else -- this falls out of
a single "household must have >=1 admin" invariant rather than needing
special-case code.

Schema: parents.is_admin, added via a new idempotent ensureColumn() helper
(SQLite has no ADD COLUMN IF NOT EXISTS, and this needed to run safely
against the already-populated production parents table on next boot, not
just fresh installs). A boot-time backfill promotes the earliest-created
parent in any household with zero admins -- verified against a simulated
copy of the real production schema/data (including the exact "spouse
joined via invite" scenario), confirming correct promotion and clean
idempotency across repeated boots.

Fixed a real foreign-key landmine along the way: household_invites.
used_by_parent_id had no ON DELETE clause, so deleting any parent who'd
ever accepted an invite -- i.e. any spouse, in this app's normal usage --
would have thrown a constraint violation. src/lib/removeParent.js nulls
that reference before deleting, wrapped in an explicit transaction (first
use of manual BEGIN/COMMIT/ROLLBACK in this codebase, verified working
with node:sqlite before relying on it).

Verified extensively: every route's permission/edge cases via curl
(cross-household isolation, sole-admin guards, password round-trips via
real login), and the full UI flow (promote/demote/remove/both password
modals/leave-with-error-toast) across two independent real browser
sessions acting as admin and non-admin simultaneously. Full-app regression
and a Docker build/boot check both pass with the new code in place.
This commit is contained in:
ort
2026-08-15 18:07:52 -04:00
parent 962d5bab7f
commit 8c7710d981
12 changed files with 340 additions and 12 deletions
+39
View File
@@ -107,6 +107,45 @@
</div>
</div>
<!-- Change own password modal -->
<div class="modal-backdrop hidden" id="changeOwnPasswordModal">
<div class="modal">
<h2>Change my password</h2>
<form id="changeOwnPasswordForm">
<div class="field">
<label for="currentPasswordInput">Current password</label>
<input type="password" id="currentPasswordInput" required>
</div>
<div class="field">
<label for="newOwnPasswordInput">New password</label>
<input type="password" id="newOwnPasswordInput" minlength="8" required>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Change password</button>
<button type="button" class="tool" id="cancelChangeOwnPasswordBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Admin sets another parent's password modal -->
<div class="modal-backdrop hidden" id="setParentPasswordModal">
<div class="modal">
<h2>Set password for <span id="setPasswordParentName"></span></h2>
<form id="setParentPasswordForm">
<input type="hidden" id="setPasswordParentId">
<div class="field">
<label for="newParentPasswordInput">New password</label>
<input type="password" id="newParentPasswordInput" minlength="8" required>
</div>
<div class="form-actions">
<button type="submit" class="tool primary">Set password</button>
<button type="button" class="tool" id="cancelSetParentPasswordBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Kiosk link modal -->
<div class="modal-backdrop hidden" id="kioskModal">
<div class="modal">
+90 -6
View File
@@ -1,4 +1,5 @@
let children = [];
let currentParent = null;
function showToast(msg) {
const toast = document.getElementById('toast');
@@ -15,8 +16,10 @@ function fmtDate(iso) {
async function boot() {
try {
const me = await api.get('/api/auth/me');
currentParent = me.parent;
document.getElementById('householdName').textContent = me.household.name;
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
document.getElementById('inviteBtn').style.display = currentParent.isAdmin ? '' : 'none';
} catch {
window.location.href = '/login.html';
return;
@@ -28,15 +31,66 @@ async function boot() {
async function loadParents() {
const { parents } = await api.get('/api/household');
const el = document.getElementById('parentsList');
el.innerHTML = '';
parents.forEach((p) => {
const card = document.createElement('div');
card.className = 'entity-card';
card.innerHTML = `<h3>${escapeHtml(p.name)}</h3><div class="meta">${escapeHtml(p.email)}</div>`;
el.appendChild(card);
el.innerHTML = parents.map((p) => parentCardHtml(p)).join('');
el.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', () => handleParentAction(btn.dataset.action, btn.dataset));
});
}
function parentCardHtml(p) {
const isSelf = p.id === currentParent.id;
const badge = p.is_admin ? '<span class="today-badge">Admin</span>' : '';
const adminActions = (currentParent.isAdmin && !isSelf) ? `
<button class="tool" data-action="${p.is_admin ? 'demote' : 'promote'}" data-parent="${p.id}">${p.is_admin ? 'Remove admin' : 'Make admin'}</button>
<button class="tool" data-action="set-password" data-parent="${p.id}" data-name="${escapeHtml(p.name)}">Set password</button>
<button class="tool danger" data-action="remove-parent" data-parent="${p.id}" data-name="${escapeHtml(p.name)}">Remove</button>
` : '';
const selfActions = isSelf ? `
<button class="tool" data-action="change-own-password">Change my password</button>
<button class="tool danger" data-action="leave">Leave household</button>
` : '';
return `
<div class="entity-card">
<h3>${escapeHtml(p.name)} ${badge}</h3>
<div class="meta">${escapeHtml(p.email)}</div>
<div class="row">${adminActions}${selfActions}</div>
</div>
`;
}
async function handleParentAction(action, ds) {
try {
if (action === 'promote' || action === 'demote') {
await api.patch(`/api/household/parents/${ds.parent}`, { isAdmin: action === 'promote' });
showToast(action === 'promote' ? 'Promoted to admin' : 'Admin removed');
await loadParents();
} else if (action === 'remove-parent') {
if (!confirm(`Remove ${ds.name} from the household?`)) return;
await api.del(`/api/household/parents/${ds.parent}`);
showToast('Parent removed');
await loadParents();
} else if (action === 'set-password') {
document.getElementById('setPasswordParentId').value = ds.parent;
document.getElementById('setPasswordParentName').textContent = ds.name;
document.getElementById('newParentPasswordInput').value = '';
openModal('setParentPasswordModal');
} else if (action === 'change-own-password') {
document.getElementById('currentPasswordInput').value = '';
document.getElementById('newOwnPasswordInput').value = '';
openModal('changeOwnPasswordModal');
} else if (action === 'leave') {
if (!confirm('Leave this household? You will lose access immediately.')) return;
await api.post('/api/household/leave');
window.location.href = '/login.html';
}
} catch (err) {
showToast(err.message);
}
}
async function loadChildren() {
const data = await api.get('/api/children');
children = data.children;
@@ -205,6 +259,36 @@ document.getElementById('regenKioskBtn').addEventListener('click', async () => {
await loadChildren();
});
document.getElementById('cancelChangeOwnPasswordBtn').addEventListener('click', () => closeModal('changeOwnPasswordModal'));
document.getElementById('changeOwnPasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api.post('/api/household/me/password', {
currentPassword: document.getElementById('currentPasswordInput').value,
newPassword: document.getElementById('newOwnPasswordInput').value,
});
closeModal('changeOwnPasswordModal');
showToast('Password changed');
} catch (err) {
showToast(err.message);
}
});
document.getElementById('cancelSetParentPasswordBtn').addEventListener('click', () => closeModal('setParentPasswordModal'));
document.getElementById('setParentPasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const parentId = document.getElementById('setPasswordParentId').value;
await api.patch(`/api/household/parents/${parentId}/password`, {
newPassword: document.getElementById('newParentPasswordInput').value,
});
closeModal('setParentPasswordModal');
showToast('Password updated');
} catch (err) {
showToast(err.message);
}
});
document.getElementById('logoutBtn').addEventListener('click', async () => {
await api.post('/api/auth/logout');
window.location.href = '/login.html';