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>
</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 --> <!-- Kiosk link modal -->
<div class="modal-backdrop hidden" id="kioskModal"> <div class="modal-backdrop hidden" id="kioskModal">
<div class="modal"> <div class="modal">
+90 -6
View File
@@ -1,4 +1,5 @@
let children = []; let children = [];
let currentParent = null;
function showToast(msg) { function showToast(msg) {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');
@@ -15,8 +16,10 @@ function fmtDate(iso) {
async function boot() { async function boot() {
try { try {
const me = await api.get('/api/auth/me'); const me = await api.get('/api/auth/me');
currentParent = me.parent;
document.getElementById('householdName').textContent = me.household.name; document.getElementById('householdName').textContent = me.household.name;
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`; document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
document.getElementById('inviteBtn').style.display = currentParent.isAdmin ? '' : 'none';
} catch { } catch {
window.location.href = '/login.html'; window.location.href = '/login.html';
return; return;
@@ -28,15 +31,66 @@ async function boot() {
async function loadParents() { async function loadParents() {
const { parents } = await api.get('/api/household'); const { parents } = await api.get('/api/household');
const el = document.getElementById('parentsList'); const el = document.getElementById('parentsList');
el.innerHTML = ''; el.innerHTML = parents.map((p) => parentCardHtml(p)).join('');
parents.forEach((p) => { el.querySelectorAll('[data-action]').forEach((btn) => {
const card = document.createElement('div'); btn.addEventListener('click', () => handleParentAction(btn.dataset.action, btn.dataset));
card.className = 'entity-card';
card.innerHTML = `<h3>${escapeHtml(p.name)}</h3><div class="meta">${escapeHtml(p.email)}</div>`;
el.appendChild(card);
}); });
} }
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() { async function loadChildren() {
const data = await api.get('/api/children'); const data = await api.get('/api/children');
children = data.children; children = data.children;
@@ -205,6 +259,36 @@ document.getElementById('regenKioskBtn').addEventListener('click', async () => {
await loadChildren(); 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 () => { document.getElementById('logoutBtn').addEventListener('click', async () => {
await api.post('/api/auth/logout'); await api.post('/api/auth/logout');
window.location.href = '/login.html'; window.location.href = '/login.html';
+31
View File
@@ -0,0 +1,31 @@
// Promotes the earliest-created parent in any household that currently has
// zero admins. Safe to run on every boot: a household with >=1 admin is a
// permanent no-op for that household from then on. Handles both the
// one-time production backfill (households that pre-date the admin column)
// and self-healing if that invariant were ever somehow violated.
function backfillAdmins(db) {
const promoted = db.prepare(`
SELECT p.id, p.household_id, p.name
FROM parents p
WHERE p.id = (
SELECT p2.id FROM parents p2
WHERE p2.household_id = p.household_id
ORDER BY p2.created_at ASC, p2.id ASC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1 FROM parents p3
WHERE p3.household_id = p.household_id AND p3.is_admin = 1
)
`).all();
if (promoted.length === 0) return;
const promoteStmt = db.prepare('UPDATE parents SET is_admin = 1 WHERE id = ?');
for (const p of promoted) {
promoteStmt.run(p.id);
console.log(`[migrate] auto-promoted "${p.name}" (parent ${p.id}) to admin for household ${p.household_id}`);
}
}
module.exports = { backfillAdmins };
+13
View File
@@ -0,0 +1,13 @@
// SQLite has no ADD COLUMN IF NOT EXISTS. schema.sql's CREATE TABLE IF NOT
// EXISTS pattern only helps fresh installs — an already-existing table
// needs its columns checked and altered explicitly. Generic since this
// need will likely recur for future schema changes against a live,
// already-populated database.
function ensureColumn(db, table, column, ddl) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some((c) => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
}
}
module.exports = { ensureColumn };
+4
View File
@@ -1,9 +1,13 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ensureColumn } = require('./columnMigrations');
const { backfillAdmins } = require('./backfillAdmins');
function migrate(db) { function migrate(db) {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema); db.exec(schema);
ensureColumn(db, 'parents', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
backfillAdmins(db);
} }
module.exports = { migrate }; module.exports = { migrate };
+1
View File
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS parents (
email TEXT NOT NULL UNIQUE COLLATE NOCASE, email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
); );
CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id); CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id);
+23
View File
@@ -0,0 +1,23 @@
const db = require('../db');
const nullifyUsedByStmt = db.prepare('UPDATE household_invites SET used_by_parent_id = NULL WHERE used_by_parent_id = ?');
const deleteParentStmt = db.prepare('DELETE FROM parents WHERE id = ?');
// household_invites.used_by_parent_id has no ON DELETE clause (SQLite
// default NO ACTION), and an existing column's REFERENCES can't be altered
// in place -- so with foreign_keys=ON, deleting a parent who ever accepted
// an invite (i.e. any spouse, in this app's normal usage) would throw a
// constraint violation unless that reference is cleared first.
function removeParentTx(parentId) {
db.exec('BEGIN');
try {
nullifyUsedByStmt.run(parentId);
deleteParentStmt.run(parentId);
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
module.exports = { removeParentTx };
+7
View File
@@ -0,0 +1,7 @@
// Must run after requireAuth — needs req.parent.is_admin populated.
function requireAdmin(req, res, next) {
if (!req.parent.is_admin) return res.status(403).json({ error: 'Admins only' });
next();
}
module.exports = requireAdmin;
+1 -1
View File
@@ -1,6 +1,6 @@
const db = require('../db'); const db = require('../db');
const getParentStmt = db.prepare('SELECT id, household_id, email, name FROM parents WHERE id = ?'); const getParentStmt = db.prepare('SELECT id, household_id, email, name, is_admin FROM parents WHERE id = ?');
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const parentId = req.session && req.session.parentId; const parentId = req.session && req.session.parentId;
+11 -3
View File
@@ -11,13 +11,19 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)'); const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)');
const insertParentStmt = db.prepare( const insertParentStmt = db.prepare(
'INSERT INTO parents (household_id, email, password_hash, name) VALUES (?, ?, ?, ?)' 'INSERT INTO parents (household_id, email, password_hash, name, is_admin) VALUES (?, ?, ?, ?, 1)'
); );
const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?'); const getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?');
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?'); const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
function publicParent(parent) { function publicParent(parent) {
return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id }; return {
id: parent.id,
email: parent.email,
name: parent.name,
householdId: parent.household_id,
isAdmin: !!parent.is_admin,
};
} }
router.post('/signup', (req, res) => { router.post('/signup', (req, res) => {
@@ -51,7 +57,9 @@ router.post('/signup', (req, res) => {
const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid; const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid;
req.session.parentId = Number(parentId); req.session.parentId = Number(parentId);
res.status(201).json({ parent: publicParent({ id: parentId, email, name: name.trim(), household_id: householdId }) }); res.status(201).json({
parent: publicParent({ id: parentId, email, name: name.trim(), household_id: householdId, is_admin: 1 }),
});
}); });
router.post('/login', (req, res) => { router.post('/login', (req, res) => {
+111 -1
View File
@@ -1,16 +1,42 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db'); const db = require('../db');
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const requireAdmin = require('../middleware/requireAdmin');
const { isNonEmptyString, LIMITS } = require('../lib/validate'); const { isNonEmptyString, LIMITS } = require('../lib/validate');
const { removeParentTx } = require('../lib/removeParent');
const router = express.Router(); const router = express.Router();
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?'); const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
const listParentsStmt = db.prepare('SELECT id, email, name, created_at FROM parents WHERE household_id = ? ORDER BY created_at'); const listParentsStmt = db.prepare('SELECT id, email, name, is_admin, created_at FROM parents WHERE household_id = ? ORDER BY created_at');
const renameHouseholdStmt = db.prepare('UPDATE households SET name = ? WHERE id = ?'); const renameHouseholdStmt = db.prepare('UPDATE households SET name = ? WHERE id = ?');
const getParentByIdStmt = db.prepare('SELECT * FROM parents WHERE id = ?');
const setAdminStmt = db.prepare('UPDATE parents SET is_admin = ? WHERE id = ?');
const countAdminsStmt = db.prepare('SELECT COUNT(*) AS n FROM parents WHERE household_id = ? AND is_admin = 1');
const setPasswordStmt = db.prepare('UPDATE parents SET password_hash = ? WHERE id = ?');
router.use(requireAuth); router.use(requireAuth);
function isSoleAdmin(parent) {
return !!parent.is_admin && countAdminsStmt.get(parent.household_id).n === 1;
}
// Loads a parent by id, verifying they're in the caller's household.
// Sends 404 and returns null if not found/not in household.
function loadHouseholdParent(req, res, targetId) {
const target = getParentByIdStmt.get(targetId);
if (!target || target.household_id !== req.parent.household_id) {
res.status(404).json({ error: 'Parent not found' });
return null;
}
return target;
}
function validateNewPassword(newPassword) {
return typeof newPassword === 'string' && newPassword.length >= 8 && newPassword.length <= LIMITS.PASSWORD;
}
router.get('/', (req, res) => { router.get('/', (req, res) => {
const household = getHouseholdStmt.get(req.parent.household_id); const household = getHouseholdStmt.get(req.parent.household_id);
const parents = listParentsStmt.all(req.parent.household_id); const parents = listParentsStmt.all(req.parent.household_id);
@@ -26,4 +52,88 @@ router.patch('/', (req, res) => {
res.json({ household: { id: req.parent.household_id, name: name.trim() } }); res.json({ household: { id: req.parent.household_id, name: name.trim() } });
}); });
router.patch('/parents/:id', requireAdmin, (req, res) => {
const target = loadHouseholdParent(req, res, req.params.id);
if (!target) return;
const { isAdmin } = req.body || {};
if (typeof isAdmin !== 'boolean') {
return res.status(400).json({ error: 'isAdmin must be a boolean' });
}
if (isAdmin === !!target.is_admin) {
return res.json({ parent: { id: target.id, isAdmin: !!target.is_admin } });
}
if (!isAdmin && isSoleAdmin(target)) {
return res.status(409).json({ error: 'Household must have at least one admin. Promote another parent first.' });
}
setAdminStmt.run(isAdmin ? 1 : 0, target.id);
res.json({ parent: { id: target.id, isAdmin } });
});
router.delete('/parents/:id', requireAdmin, (req, res) => {
if (Number(req.params.id) === req.parent.id) {
return res.status(400).json({ error: 'Use /api/household/leave to remove yourself' });
}
const target = loadHouseholdParent(req, res, req.params.id);
if (!target) return;
if (isSoleAdmin(target)) {
return res.status(409).json({ error: 'Household must have at least one admin. Promote another parent first.' });
}
removeParentTx(target.id);
res.status(204).end();
});
router.post('/leave', (req, res) => {
if (isSoleAdmin(req.parent)) {
return res.status(409).json({ error: "You're the only admin. Promote another parent before leaving." });
}
removeParentTx(req.parent.id);
req.session.destroy(() => {
res.clearCookie('kc.sid');
res.status(204).end();
});
});
router.post('/me/password', (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (typeof currentPassword !== 'string') {
return res.status(400).json({ error: 'Current password is required' });
}
if (!validateNewPassword(newPassword)) {
return res.status(400).json({ error: `New password must be 8-${LIMITS.PASSWORD} characters` });
}
const full = getParentByIdStmt.get(req.parent.id);
if (!bcrypt.compareSync(currentPassword, full.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
setPasswordStmt.run(bcrypt.hashSync(newPassword, 12), req.parent.id);
res.status(204).end();
});
router.patch('/parents/:id/password', requireAdmin, (req, res) => {
if (Number(req.params.id) === req.parent.id) {
return res.status(400).json({ error: 'Use /api/household/me/password to change your own password' });
}
const target = loadHouseholdParent(req, res, req.params.id);
if (!target) return;
const { newPassword } = req.body || {};
if (!validateNewPassword(newPassword)) {
return res.status(400).json({ error: `Password must be 8-${LIMITS.PASSWORD} characters` });
}
setPasswordStmt.run(bcrypt.hashSync(newPassword, 12), target.id);
res.status(204).end();
});
module.exports = router; module.exports = router;
+9 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const db = require('../db'); const db = require('../db');
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const requireAdmin = require('../middleware/requireAdmin');
const { randomToken } = require('../lib/tokens'); const { randomToken } = require('../lib/tokens');
const { isNonEmptyString, LIMITS } = require('../lib/validate'); const { isNonEmptyString, LIMITS } = require('../lib/validate');
@@ -25,12 +26,19 @@ const insertParentStmt = db.prepare(
); );
function publicParent(parent) { function publicParent(parent) {
return { id: parent.id, email: parent.email, name: parent.name, householdId: parent.household_id }; return {
id: parent.id,
email: parent.email,
name: parent.name,
householdId: parent.household_id,
isAdmin: !!parent.is_admin,
};
} }
// Session-authenticated: manage invites for the caller's own household. // Session-authenticated: manage invites for the caller's own household.
const manageRouter = express.Router(); const manageRouter = express.Router();
manageRouter.use(requireAuth); manageRouter.use(requireAuth);
manageRouter.use(requireAdmin);
manageRouter.post('/', (req, res) => { manageRouter.post('/', (req, res) => {
const token = randomToken(); const token = randomToken();