Add accounts and multi-tenancy

Every event, profile and photo is now scoped to a signed-in account, so
separate people can track separate puppies on one server.

Server:
- users + sessions tables; bcrypt passwords; random session tokens stored
  hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a
  valid session.
- register/login/logout/me endpoints. Registration requires a shared invite
  code (-invite-code / PUPPY_INVITE_CODE); empty disables it.
- events, config and photos are keyed by user_id; the sync upsert guards
  against cross-user overwrites and reads are scoped, so accounts are isolated.
  Photos live under photos/<user_id>/ and are only served to their owner.
- in-place schema migration adds user_id and reshapes config; legacy
  single-tenant data (including imported events.json) is parked ownerless and
  adopted by the first account to register.

Client:
- login/register gate in front of the app; the tracker only boots once the
  session check resolves. localStorage is namespaced per user.
- 401s bounce back to login; an offline reload falls back to the last cached
  session so offline-first still works. Logout clears the session and reloads.

Deployment:
- module.nix gains inviteCodeFile (secret via EnvironmentFile) and
  secureCookies options.

Verified end to end (curl + a headless-browser run of the auth flow):
isolation between accounts, invite enforcement, first-user adoption, photo
ownership, and session persistence across reload.
This commit is contained in:
Alexander Heldt
2026-07-09 18:20:36 +00:00
parent 9207aaa4aa
commit acf2931fb4
11 changed files with 896 additions and 90 deletions
+179 -33
View File
@@ -1,8 +1,13 @@
(() => {
"use strict";
const STORAGE_KEY = "puppy-tracker:events:v1";
const CONFIG_KEY = "puppy-tracker:config:v1";
// Storage is namespaced per account so two people sharing a browser (or one
// person logging out and back in as someone else) never see each other's
// cached events/profile. currentUser is set by the auth gate before the app
// boots, so these are only ever called once a user is known.
let currentUser = null;
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000;
@@ -178,7 +183,7 @@
// Internal "raw" storage includes deleted tombstones; UI uses live().
function loadAll() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(eventsKey());
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
@@ -193,7 +198,7 @@
}
function saveAll(events) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
localStorage.setItem(eventsKey(), JSON.stringify(events));
}
function live() {
@@ -206,7 +211,7 @@
// source of truth, reconciled by last-write-wins on updatedAt (see syncConfig).
function loadConfig() {
try {
const parsed = JSON.parse(localStorage.getItem(CONFIG_KEY));
const parsed = JSON.parse(localStorage.getItem(configKey()));
if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", updatedAt: 0 };
return {
name: parsed.name || "",
@@ -219,7 +224,7 @@
}
function saveConfig(cfg) {
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
localStorage.setItem(configKey(), JSON.stringify(cfg));
}
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
@@ -1176,6 +1181,7 @@
}
async function sync() {
if (!currentUser) return;
if (syncing) return;
if (!navigator.onLine) { setStatus("pending"); return; }
syncing = true;
@@ -1190,6 +1196,7 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: loadAll() }),
});
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (Array.isArray(body.events)) {
@@ -1214,10 +1221,12 @@
// (e.g. edited on this device while another client hadn't changed it). This
// self-heals a failed push — the local copy stays newer and re-pushes next tick.
async function syncConfig() {
if (!currentUser) return;
if (!navigator.onLine) return;
const local = loadConfig();
try {
const res = await fetch("api/config");
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const server = {
@@ -1542,37 +1551,174 @@
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus());
// Live-update relative times and (eventually) sync status text.
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderStats(evs);
renderLasts(evs);
renderTiming(evs);
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
// Big-clock counter updates once a second without re-deriving state.
setInterval(tickBigClock, 1000);
// Periodic pull from server so other clients' changes show up.
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
// Service worker
// Service worker (independent of auth).
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err));
});
}
// First paint + initial sync.
setStatus();
render();
sync();
syncConfig();
// ---------- auth gate ----------
// The tracker only boots once we know who the user is. startApp() does the
// first paint, initial sync, and starts the periodic timers — guarded so it
// runs at most once per page load even if login and the session check race.
const authScreen = document.getElementById("auth-screen");
const appEl = document.getElementById("app");
const authForm = document.getElementById("auth-form");
const authEmail = document.getElementById("auth-email");
const authPassword = document.getElementById("auth-password");
const authInvite = document.getElementById("auth-invite");
const authInviteFld= document.getElementById("auth-invite-field");
const authError = document.getElementById("auth-error");
const authSubmit = document.getElementById("auth-submit");
const authSub = document.getElementById("auth-sub");
const authToggleBtn= document.getElementById("auth-toggle-btn");
const authToggleTxt= document.getElementById("auth-toggle-text");
let authMode = "login"; // or "register"
let appStarted = false;
// Remember who was last signed in so an offline reload can still open the
// app against the cached data instead of stranding the user on a login screen
// it can't verify. Cleared only on an explicit logout or a server 401.
const SESSION_KEY = "puppy-tracker:session:v1";
function setUser(u) {
currentUser = u;
try { localStorage.setItem(SESSION_KEY, JSON.stringify(u)); } catch { /* ignore */ }
}
function clearUser() {
currentUser = null;
try { localStorage.removeItem(SESSION_KEY); } catch { /* ignore */ }
}
function cachedUser() {
try {
const u = JSON.parse(localStorage.getItem(SESSION_KEY));
return u && u.id ? u : null;
} catch { return null; }
}
function startApp() {
if (appStarted) return;
appStarted = true;
// Live-update relative times and (eventually) sync status text.
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderStats(evs);
renderLasts(evs);
renderTiming(evs);
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
setInterval(tickBigClock, 1000);
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
setStatus();
render();
sync();
syncConfig();
}
function showAuth() {
appEl.hidden = true;
authScreen.hidden = false;
}
function showApp() {
authScreen.hidden = true;
appEl.hidden = false;
}
// Called when the server reports we're no longer authenticated (expired or
// revoked session). Drop back to the login screen without wiping the local
// cache — logging back in as the same user picks it straight back up.
function handleLoggedOut() {
clearUser();
setStatus("offline");
showAuth();
}
function renderAuthMode() {
const reg = authMode === "register";
authInviteFld.hidden = !reg;
authInvite.required = reg;
authSubmit.textContent = reg ? "Create account" : "Sign in";
authSub.textContent = reg ? "Create your account" : "Sign in to continue";
authToggleTxt.textContent = reg ? "Already have an account?" : "No account yet?";
authToggleBtn.textContent = reg ? "Sign in" : "Create one";
authPassword.autocomplete = reg ? "new-password" : "current-password";
authError.hidden = true;
}
authToggleBtn.addEventListener("click", () => {
authMode = authMode === "login" ? "register" : "login";
renderAuthMode();
});
authForm.addEventListener("submit", async (e) => {
e.preventDefault();
authError.hidden = true;
authSubmit.disabled = true;
const body = { email: authEmail.value.trim(), password: authPassword.value };
if (authMode === "register") body.invite = authInvite.value.trim();
try {
const res = await fetch(authMode === "register" ? "api/register" : "api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = (await res.text()).trim();
throw new Error(msg || `HTTP ${res.status}`);
}
setUser(await res.json());
authForm.reset();
showApp();
startApp();
} catch (err) {
authError.textContent = err.message || "Something went wrong";
authError.hidden = false;
} finally {
authSubmit.disabled = false;
}
});
document.getElementById("logout-btn").addEventListener("click", async () => {
try { await fetch("api/logout", { method: "POST" }); } catch { /* ignore */ }
clearUser();
// Full reload is the simplest way to clear in-memory app state and timers.
location.reload();
});
// On load, ask the server who we are. A valid session boots straight into the
// app. A 401 means log in. A network failure (offline PWA) falls back to the
// last cached session so offline data stays reachable — a later sync will
// 401 and bounce to login if that session has actually gone stale.
(async function bootstrap() {
try {
const res = await fetch("api/me");
if (res.ok) {
setUser(await res.json());
showApp();
startApp();
return;
}
clearUser(); // explicit 401/403: session is gone
} catch {
const cached = cachedUser();
if (cached) {
currentUser = cached;
showApp();
startApp();
return;
}
}
renderAuthMode();
showAuth();
})();
})();