ort 8c7710d981 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.
2026-08-15 18:07:52 -04:00
2026-08-15 13:48:01 -04:00
2026-08-15 13:48:01 -04:00
2026-08-15 13:48:01 -04:00
2026-08-15 13:48:01 -04:00

Kids Calendar

A self-hosted weekly calendar for your family. Parents log in and edit; each child gets a read-only "kiosk" link for a tablet, where they can only tick off tasks that are already there.

  • Parents: sign up, invite your spouse, create a calendar per child, edit freely, print, duplicate a calendar for the next week.
  • Kids: open their kiosk link on a tablet and add it to the home screen — it launches full-screen with no browser chrome, and they can see the board and check boxes — nothing else.
  • Everything a child checks off shows up on the parents' devices within a few seconds, no reload needed — and, if push notifications are set up, as an actual notification too (see below).

Running it

Requires Node.js 22.5+ (for the built-in node:sqlite module — nothing else to compile, no native build tools needed).

npm install
export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
node server.js

Plain node doesn't read .env files — export the variables from .env.example yourself (as above), or use a process manager (pm2, systemd) that does it for you. Docker (below) reads .env automatically. Then open http://localhost:3007/signup.html.

cp .env.example .env   # set SESSION_SECRET
docker compose up -d --build

Data lives in a named volume (kids-calendar-data), so it survives container rebuilds. To back it up, back up that volume (or bind-mount ./data:/app/data in docker-compose.yml instead and back up that folder).

Running on Unraid specifically? See UNRAID.md — it uses docker-compose.unraid.yml, which bind-mounts to /mnt/user/appdata/kids-calendar instead of a named volume, matching Unraid's usual appdata convention.

Environment variables

Variable Default Notes
SESSION_SECRET (insecure dev default) Required. A long random string — see .env.example for how to generate one.
PORT 3007
DATA_DIR ./data Where the SQLite file lives.
COOKIE_SECURE false Set true once this is behind HTTPS (e.g. a reverse proxy with a real certificate), so cookies are marked secure. Leave false for plain-HTTP LAN access, or login cookies won't be sent.
DISABLE_PUBLIC_SIGNUP false Set true once your household(s) exist, to stop the /signup.html page from creating new ones. Existing invite links still work.
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT unset Optional — enables push notifications. See "Push notifications" below. Leave all three unset to skip the feature entirely; the app degrades gracefully (no broken UI).

Push notifications

When a kid checks off a task on the kiosk, parents can get a real push notification instead of only the silent polling-based update. This is optional — the app works fully without it.

Requires real HTTPS. Not "it's nice to have," a hard platform requirement: Web Push needs a secure context, and iOS Safari specifically requires a browser-trusted certificate (a self-signed one with a click-through warning does not count). http://localhost is a spec-defined exception for local development, but LAN access via http://<ip>:3007 will never get push working on iOS. See "Exposing this beyond your home network" below for your HTTPS options — you need one of those in place first.

Setup, once HTTPS is sorted:

  1. Generate a VAPID keypair: npx web-push generate-vapid-keys (or docker run --rm node:22-alpine npx web-push generate-vapid-keys if you don't have Node locally).
  2. Set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT (mailto:you@example.com or an https: URL) in .env, then restart.
  3. On the dashboard, each parent clicks "Enable notifications on this device" — this is per-device, so do it on every phone/computer that should get notified.

On iPhone/iPad specifically: Safari only allows push notifications for web apps added to the Home Screen — a regular Safari tab can't subscribe at all. Tap Share → Add to Home Screen on the dashboard first, then open the app icon from your Home Screen and enable notifications from there. The dashboard's notification card explains this in place if it detects it's needed.

Regenerating the VAPID keypair invalidates every existing subscription (everyone would need to re-enable notifications) — treat it as a one-time setup step, not something to rotate casually.

How access works

  • Parents have real accounts (email + password) tied to a household. The first person to sign up creates the household; from the dashboard they can generate an invite link (valid 7 days, one-time use) for a spouse to join the same household with their own login.
  • Kids never get an account. Each child has an unguessable link (/k/<token>) that shows only their active calendar (set from the dashboard) and lets them toggle checkboxes — the server has no route at all for a kiosk link to edit text, add/delete tasks, or see other children's data. If a tablet is lost, regenerate that child's link from the dashboard to invalidate the old one.

Exposing this beyond your home network

This is built for LAN use by default (plain HTTP, cookies not marked secure). If you want access from outside your home:

  • Put it behind a reverse proxy (e.g. Caddy, nginx, Traefik) that terminates HTTPS, then set COOKIE_SECURE=true.
  • Set SESSION_SECRET to a real random value (never the dev default).
  • Consider setting DISABLE_PUBLIC_SIGNUP=true once your family's household(s) are created.

This same HTTPS setup is also the prerequisite for push notifications (see above) — a real, browser-trusted certificate, not a self-signed one. Options that satisfy this: a domain + Let's Encrypt via the reverse proxy itself, a locally-trusted CA like mkcert with its root profile installed on your devices, or a tunnel (Tailscale Funnel, Cloudflare Tunnel) that terminates real HTTPS for you.

S
Description
No description provided
Readme
182 KiB
Languages
JavaScript 76.3%
HTML 15.4%
CSS 8.1%
Dockerfile 0.2%