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';
|
||||
|
||||
@@ -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 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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
+11
-3
@@ -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) => {
|
||||
|
||||
+111
-1
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user