diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 936329f..064b06c 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -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 = `
${escapeHtml(p.name)}
${escapeHtml(p.email)}
`;
- 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 ? '
Admin' : '';
+
+ const adminActions = (currentParent.isAdmin && !isSelf) ? `
+
+
+
+ ` : '';
+
+ const selfActions = isSelf ? `
+
+
+ ` : '';
+
+ return `
+
+
${escapeHtml(p.name)} ${badge}
+
${escapeHtml(p.email)}
+
${adminActions}${selfActions}
+
+ `;
+}
+
+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';
diff --git a/src/db/backfillAdmins.js b/src/db/backfillAdmins.js
new file mode 100644
index 0000000..ddc7736
--- /dev/null
+++ b/src/db/backfillAdmins.js
@@ -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 };
diff --git a/src/db/columnMigrations.js b/src/db/columnMigrations.js
new file mode 100644
index 0000000..10acada
--- /dev/null
+++ b/src/db/columnMigrations.js
@@ -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 };
diff --git a/src/db/migrate.js b/src/db/migrate.js
index cf1e888..6a57811 100644
--- a/src/db/migrate.js
+++ b/src/db/migrate.js
@@ -1,9 +1,13 @@
const fs = require('node:fs');
const path = require('node:path');
+const { ensureColumn } = require('./columnMigrations');
+const { backfillAdmins } = require('./backfillAdmins');
function migrate(db) {
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
+ ensureColumn(db, 'parents', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
+ backfillAdmins(db);
}
module.exports = { migrate };
diff --git a/src/db/schema.sql b/src/db/schema.sql
index e9b6a2b..6dadbdc 100644
--- a/src/db/schema.sql
+++ b/src/db/schema.sql
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS parents (
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash 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'))
);
CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id);
diff --git a/src/lib/removeParent.js b/src/lib/removeParent.js
new file mode 100644
index 0000000..d949d89
--- /dev/null
+++ b/src/lib/removeParent.js
@@ -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 };
diff --git a/src/middleware/requireAdmin.js b/src/middleware/requireAdmin.js
new file mode 100644
index 0000000..4894e74
--- /dev/null
+++ b/src/middleware/requireAdmin.js
@@ -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;
diff --git a/src/middleware/requireAuth.js b/src/middleware/requireAuth.js
index 4e8f258..bb59f31 100644
--- a/src/middleware/requireAuth.js
+++ b/src/middleware/requireAuth.js
@@ -1,6 +1,6 @@
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) {
const parentId = req.session && req.session.parentId;
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 02f26d8..4adf37e 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -11,13 +11,19 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)');
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 getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
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) => {
@@ -51,7 +57,9 @@ router.post('/signup', (req, res) => {
const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid;
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) => {
diff --git a/src/routes/household.js b/src/routes/household.js
index a7dc45a..f367465 100644
--- a/src/routes/household.js
+++ b/src/routes/household.js
@@ -1,16 +1,42 @@
const express = require('express');
+const bcrypt = require('bcryptjs');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
+const requireAdmin = require('../middleware/requireAdmin');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
+const { removeParentTx } = require('../lib/removeParent');
const router = express.Router();
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 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);
+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) => {
const household = getHouseholdStmt.get(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() } });
});
+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;
diff --git a/src/routes/invites.js b/src/routes/invites.js
index 6b3f96d..234ad6a 100644
--- a/src/routes/invites.js
+++ b/src/routes/invites.js
@@ -2,6 +2,7 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const requireAuth = require('../middleware/requireAuth');
+const requireAdmin = require('../middleware/requireAdmin');
const { randomToken } = require('../lib/tokens');
const { isNonEmptyString, LIMITS } = require('../lib/validate');
@@ -25,12 +26,19 @@ const insertParentStmt = db.prepare(
);
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.
const manageRouter = express.Router();
manageRouter.use(requireAuth);
+manageRouter.use(requireAdmin);
manageRouter.post('/', (req, res) => {
const token = randomToken();