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:
+179
-33
@@ -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();
|
||||
})();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user