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:
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user