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