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.
298 lines
12 KiB
JavaScript
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();
|