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();
})();
})();
+29
View File
@@ -11,6 +11,33 @@
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden>
<div class="auth-card">
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub" id="auth-sub">Sign in to continue</p>
<form id="auth-form">
<label>Email
<input type="email" id="auth-email" autocomplete="username" required />
</label>
<label>Password
<input type="password" id="auth-password" autocomplete="current-password" required minlength="8" />
</label>
<label id="auth-invite-field" hidden>Invite code
<input type="text" id="auth-invite" autocomplete="off" placeholder="Ask the owner for this" />
</label>
<p id="auth-error" class="auth-error" hidden></p>
<button type="submit" id="auth-submit">Sign in</button>
</form>
<p class="auth-toggle">
<span id="auth-toggle-text">No account yet?</span>
<button type="button" id="auth-toggle-btn" class="linklike">Create one</button>
</p>
</div>
</div>
<div id="app" hidden>
<header>
<div class="title">
<h1 id="app-title">🐶 Puppy Tracker</h1>
@@ -18,6 +45,7 @@
</div>
<div class="header-actions">
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
<button type="button" id="logout-btn" class="ghost icon-btn" aria-label="Log out" title="Log out">🚪</button>
<div id="online-status" class="status-pill"></div>
</div>
</header>
@@ -149,6 +177,7 @@
<p id="empty-state" class="empty">No events logged for this day.</p>
</section>
</main>
</div><!-- /#app -->
<dialog id="settings-dialog">
<form method="dialog" id="settings-form">
+66
View File
@@ -533,3 +533,69 @@ dialog menu {
.lg.pee .sw { background: var(--pee); }
.lg.poo .sw { background: var(--poo); }
.lg.eat .sw { background: var(--eat); }
/* ---------- auth (login / register) ---------- */
.auth-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--bg);
z-index: 50;
}
/* The display rule above beats the UA [hidden] rule, so hide explicitly. */
.auth-screen[hidden] { display: none; }
.auth-card {
width: 100%;
max-width: 360px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px 22px;
}
.auth-card h1 { margin: 0 0 4px; font-size: 1.5rem; text-align: center; }
.auth-sub { margin: 0 0 20px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.auth-card label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 12px;
}
.auth-card input[type="email"],
.auth-card input[type="password"],
.auth-card input[type="text"] {
font: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
width: 100%;
margin-top: 4px;
}
.auth-card #auth-submit { width: 100%; margin-top: 6px; }
.auth-error {
color: var(--danger);
font-size: 0.85rem;
margin: 0 0 12px;
text-align: center;
}
.auth-toggle {
margin: 16px 0 0;
text-align: center;
font-size: 0.85rem;
color: var(--muted);
}
button.linklike {
background: none;
border: none;
color: var(--accent);
font-weight: 600;
padding: 0 0 0 4px;
display: inline;
cursor: pointer;
}
button.linklike:hover { text-decoration: underline; filter: none; }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "puppy-tracker-v5";
const CACHE = "puppy-tracker-v6";
const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [
"./",