Files
ort 8c7710d981 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.
2026-08-15 18:07:52 -04:00

298 lines
12 KiB
JavaScript

let children = [];
let currentParent = null;
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2200);
}
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
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;
}
await Promise.all([loadParents(), loadChildren()]);
}
async function loadParents() {
const { parents } = await api.get('/api/household');
const el = document.getElementById('parentsList');
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;
const el = document.getElementById('childrenList');
el.innerHTML = '';
if (children.length === 0) {
el.innerHTML = '<div class="empty-state">No children yet — add one to create their first calendar.</div>';
return;
}
for (const child of children) {
const card = await renderChildCard(child);
el.appendChild(card);
}
}
async function renderChildCard(child) {
const { calendars } = await api.get(`/api/children/${child.id}/calendars`);
const card = document.createElement('div');
card.className = 'entity-card';
const calendarsHtml = calendars.length
? calendars.map((c) => calendarRowHtml(child, c)).join('')
: '<div class="meta">No calendars yet.</div>';
card.innerHTML = `
<h3>${escapeHtml(child.name)}</h3>
<div class="meta">${calendars.length} calendar${calendars.length === 1 ? '' : 's'}</div>
<div>${calendarsHtml}</div>
<div class="row">
<button class="tool primary" data-action="new-cal" data-child="${child.id}">+ New calendar</button>
<button class="tool" data-action="kiosk-link" data-child="${child.id}">Tablet link</button>
<button class="tool danger" data-action="delete-child" data-child="${child.id}">Delete child</button>
</div>
`;
card.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', () => handleCardAction(btn.dataset.action, btn.dataset));
});
return card;
}
function calendarRowHtml(child, cal) {
const isActive = child.activeCalendarId === cal.id;
return `
<div class="row" style="justify-content:space-between; align-items:center; padding:6px 0; border-top:1px solid var(--line);">
<div>
<strong>${escapeHtml(cal.title)}</strong>
<div class="meta">Updated ${fmtDate(cal.updated_at)}${isActive ? ' · on tablet' : ''}</div>
</div>
<div class="row">
<a class="tool" href="/calendar.html?calendarId=${cal.id}">Open</a>
<button class="tool" data-action="duplicate-cal" data-child="${child.id}" data-cal="${cal.id}" data-title="${escapeHtml(cal.title)}">Duplicate</button>
${isActive ? '' : `<button class="tool" data-action="set-active" data-child="${child.id}" data-cal="${cal.id}">Set on tablet</button>`}
<button class="tool danger" data-action="delete-cal" data-child="${child.id}" data-cal="${cal.id}">Delete</button>
</div>
</div>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async function handleCardAction(action, ds) {
if (action === 'new-cal') {
document.getElementById('newCalChildId').value = ds.child;
document.getElementById('newCalTitle').value = '';
document.getElementById('newCalDuplicate').checked = false;
openModal('newCalendarModal');
} else if (action === 'duplicate-cal') {
const title = prompt('Title for the duplicated calendar:', `Copy of ${ds.title}`);
if (!title) return;
await api.post(`/api/children/${ds.child}/calendars`, { title, duplicateFromCalendarId: Number(ds.cal) });
showToast('Calendar duplicated');
await loadChildren();
} else if (action === 'set-active') {
await api.patch(`/api/children/${ds.child}`, { activeCalendarId: Number(ds.cal) });
showToast('Tablet will now show this calendar');
await loadChildren();
} else if (action === 'delete-cal') {
if (!confirm('Delete this calendar? This cannot be undone.')) return;
await api.del(`/api/calendars/${ds.cal}`);
showToast('Calendar deleted');
await loadChildren();
} else if (action === 'kiosk-link') {
const child = children.find((c) => c.id === Number(ds.child));
openKioskModal(child);
} else if (action === 'delete-child') {
if (!confirm('Delete this child and all their calendars? This cannot be undone.')) return;
await api.del(`/api/children/${ds.child}`);
showToast('Child deleted');
await loadChildren();
}
}
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
document.getElementById('addChildBtn').addEventListener('click', () => {
document.getElementById('childNameInput').value = '';
openModal('addChildModal');
});
document.getElementById('cancelAddChildBtn').addEventListener('click', () => closeModal('addChildModal'));
document.getElementById('addChildForm').addEventListener('submit', async (e) => {
e.preventDefault();
await api.post('/api/children', { name: document.getElementById('childNameInput').value });
closeModal('addChildModal');
showToast('Child added');
await loadChildren();
});
document.getElementById('cancelNewCalBtn').addEventListener('click', () => closeModal('newCalendarModal'));
document.getElementById('newCalendarForm').addEventListener('submit', async (e) => {
e.preventDefault();
const childId = document.getElementById('newCalChildId').value;
const title = document.getElementById('newCalTitle').value;
const duplicate = document.getElementById('newCalDuplicate').checked;
let body = { title };
if (duplicate) {
const { calendars } = await api.get(`/api/children/${childId}/calendars`);
if (calendars.length) body.duplicateFromCalendarId = calendars[0].id;
}
const { calendar } = await api.post(`/api/children/${childId}/calendars`, body);
closeModal('newCalendarModal');
window.location.href = `/calendar.html?calendarId=${calendar.id}`;
});
document.getElementById('inviteBtn').addEventListener('click', async () => {
const { invite } = await api.post('/api/household/invites');
const link = `${window.location.origin}${invite.joinPath}`;
document.getElementById('inviteLinkField').value = link;
openModal('inviteModal');
});
document.getElementById('closeInviteBtn').addEventListener('click', () => closeModal('inviteModal'));
document.getElementById('copyInviteBtn').addEventListener('click', () => {
document.getElementById('inviteLinkField').select();
navigator.clipboard.writeText(document.getElementById('inviteLinkField').value);
showToast('Link copied');
});
let kioskModalChild = null;
function openKioskModal(child) {
kioskModalChild = child;
document.getElementById('kioskChildName').textContent = child.name;
document.getElementById('kioskLinkField').value = `${window.location.origin}${child.kioskPath}`;
openModal('kioskModal');
}
document.getElementById('closeKioskBtn').addEventListener('click', () => closeModal('kioskModal'));
document.getElementById('copyKioskBtn').addEventListener('click', () => {
document.getElementById('kioskLinkField').select();
navigator.clipboard.writeText(document.getElementById('kioskLinkField').value);
showToast('Link copied');
});
document.getElementById('regenKioskBtn').addEventListener('click', async () => {
if (!confirm('Regenerate this tablet link? The old link will stop working.')) return;
const data = await api.post(`/api/children/${kioskModalChild.id}/kiosk-token/regenerate`);
document.getElementById('kioskLinkField').value = `${window.location.origin}${data.kioskPath}`;
showToast('Link regenerated');
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';
});
boot();