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