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>
|
||||||
</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 -->
|
<!-- Kiosk link modal -->
|
||||||
<div class="modal-backdrop hidden" id="kioskModal">
|
<div class="modal-backdrop hidden" id="kioskModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|||||||
+90
-6
@@ -1,4 +1,5 @@
|
|||||||
let children = [];
|
let children = [];
|
||||||
|
let currentParent = null;
|
||||||
|
|
||||||
function showToast(msg) {
|
function showToast(msg) {
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
@@ -15,8 +16,10 @@ function fmtDate(iso) {
|
|||||||
async function boot() {
|
async function boot() {
|
||||||
try {
|
try {
|
||||||
const me = await api.get('/api/auth/me');
|
const me = await api.get('/api/auth/me');
|
||||||
|
currentParent = me.parent;
|
||||||
document.getElementById('householdName').textContent = me.household.name;
|
document.getElementById('householdName').textContent = me.household.name;
|
||||||
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
|
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
|
||||||
|
document.getElementById('inviteBtn').style.display = currentParent.isAdmin ? '' : 'none';
|
||||||
} catch {
|
} catch {
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
return;
|
return;
|
||||||
@@ -28,15 +31,66 @@ async function boot() {
|
|||||||
async function loadParents() {
|
async function loadParents() {
|
||||||
const { parents } = await api.get('/api/household');
|
const { parents } = await api.get('/api/household');
|
||||||
const el = document.getElementById('parentsList');
|
const el = document.getElementById('parentsList');
|
||||||
el.innerHTML = '';
|
el.innerHTML = parents.map((p) => parentCardHtml(p)).join('');
|
||||||
parents.forEach((p) => {
|
el.querySelectorAll('[data-action]').forEach((btn) => {
|
||||||
const card = document.createElement('div');
|
btn.addEventListener('click', () => handleParentAction(btn.dataset.action, btn.dataset));
|
||||||
card.className = 'entity-card';
|
|
||||||
card.innerHTML = `<h3>${escapeHtml(p.name)}</h3><div class="meta">${escapeHtml(p.email)}</div>`;
|
|
||||||
el.appendChild(card);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function loadChildren() {
|
||||||
const data = await api.get('/api/children');
|
const data = await api.get('/api/children');
|
||||||
children = data.children;
|
children = data.children;
|
||||||
@@ -205,6 +259,36 @@ document.getElementById('regenKioskBtn').addEventListener('click', async () => {
|
|||||||
await loadChildren();
|
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 () => {
|
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||||
await api.post('/api/auth/logout');
|
await api.post('/api/auth/logout');
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const { ensureColumn } = require('./columnMigrations');
|
||||||
|
const { backfillAdmins } = require('./backfillAdmins');
|
||||||
|
|
||||||
function migrate(db) {
|
function migrate(db) {
|
||||||
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
ensureColumn(db, 'parents', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
backfillAdmins(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { migrate };
|
module.exports = { migrate };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS parents (
|
|||||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
name 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'))
|
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);
|
CREATE INDEX IF NOT EXISTS idx_parents_household ON parents(household_id);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const db = require('../db');
|
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) {
|
function requireAuth(req, res, next) {
|
||||||
const parentId = req.session && req.session.parentId;
|
const parentId = req.session && req.session.parentId;
|
||||||
|
|||||||
+11
-3
@@ -11,13 +11,19 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||||||
|
|
||||||
const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)');
|
const insertHouseholdStmt = db.prepare('INSERT INTO households (name) VALUES (?)');
|
||||||
const insertParentStmt = db.prepare(
|
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 getParentByEmailStmt = db.prepare('SELECT * FROM parents WHERE email = ?');
|
||||||
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
|
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
|
||||||
|
|
||||||
function publicParent(parent) {
|
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) => {
|
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;
|
const parentId = insertParentStmt.run(householdId, email.toLowerCase(), passwordHash, name.trim()).lastInsertRowid;
|
||||||
|
|
||||||
req.session.parentId = Number(parentId);
|
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) => {
|
router.post('/login', (req, res) => {
|
||||||
|
|||||||
+111
-1
@@ -1,16 +1,42 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const requireAdmin = require('../middleware/requireAdmin');
|
||||||
const { isNonEmptyString, LIMITS } = require('../lib/validate');
|
const { isNonEmptyString, LIMITS } = require('../lib/validate');
|
||||||
|
const { removeParentTx } = require('../lib/removeParent');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const getHouseholdStmt = db.prepare('SELECT * FROM households WHERE id = ?');
|
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 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);
|
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) => {
|
router.get('/', (req, res) => {
|
||||||
const household = getHouseholdStmt.get(req.parent.household_id);
|
const household = getHouseholdStmt.get(req.parent.household_id);
|
||||||
const parents = listParentsStmt.all(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() } });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const requireAdmin = require('../middleware/requireAdmin');
|
||||||
const { randomToken } = require('../lib/tokens');
|
const { randomToken } = require('../lib/tokens');
|
||||||
const { isNonEmptyString, LIMITS } = require('../lib/validate');
|
const { isNonEmptyString, LIMITS } = require('../lib/validate');
|
||||||
|
|
||||||
@@ -25,12 +26,19 @@ const insertParentStmt = db.prepare(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function publicParent(parent) {
|
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.
|
// Session-authenticated: manage invites for the caller's own household.
|
||||||
const manageRouter = express.Router();
|
const manageRouter = express.Router();
|
||||||
manageRouter.use(requireAuth);
|
manageRouter.use(requireAuth);
|
||||||
|
manageRouter.use(requireAdmin);
|
||||||
|
|
||||||
manageRouter.post('/', (req, res) => {
|
manageRouter.post('/', (req, res) => {
|
||||||
const token = randomToken();
|
const token = randomToken();
|
||||||
|
|||||||
Reference in New Issue
Block a user