From 706d8d8d9f39c857912a1871807e3a2100e565bd Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 9 Jul 2026 18:20:36 +0000 Subject: [PATCH] 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// 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. Co-Authored-By: Claude Opus 4.8 --- README.md | 43 +++++- flake.nix | 2 +- module.nix | 30 +++- server/auth.go | 383 +++++++++++++++++++++++++++++++++++++++++++++++++ server/go.mod | 7 +- server/go.sum | 6 +- server/main.go | 206 ++++++++++++++++++++------ src/app.js | 212 ++++++++++++++++++++++----- src/index.html | 29 ++++ src/style.css | 66 +++++++++ src/sw.js | 2 +- 11 files changed, 896 insertions(+), 90 deletions(-) create mode 100644 server/auth.go diff --git a/README.md b/README.md index a175650..98d92a2 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,15 @@ source-of-truth and sync between devices. `config.json` sitting alongside it, renaming them to `*.imported`. - Service worker bypasses cache for `/api/*` so writes always hit the server when online; static assets are still cached for offline use. -- The puppy's name and birthday are a shared profile stored on the host +- The puppy's name and birthday are a per-account profile stored on the host (`GET`/`PUT /api/config`), so a new device picks them up automatically instead of being configured per-client. The client caches the last-seen values in `localStorage` for offline/instant paint and reconciles with the server by last-write-wins on `updatedAt`. The age shown in the header (in weeks and months) is derived from the birthday. +- All data is scoped to the signed-in account (see [Accounts](#accounts)): every + event, profile and photo carries a `user_id`, and `localStorage` is namespaced + per user so two accounts on one browser never mix. A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` / `sync error` / `offline`. Tap it to force-sync. @@ -40,7 +43,8 @@ puppy-tracker/ ├── server/ │ ├── go.mod │ ├── go.sum -│ └── main.go # SQLite store, LWW sync, static file serving +│ ├── main.go # SQLite store, LWW sync, static file serving +│ └── auth.go # accounts, sessions, invite-gated registration └── src/ # the web app ├── index.html ├── app.js @@ -56,10 +60,32 @@ puppy-tracker/ nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker PUPPY_ADDR=:9000 nix run # custom port +# Registration needs an invite code (see Accounts). Set it in the environment: +PUPPY_INVITE_CODE=letmein nix run + # Hot-iterate (data in /tmp): -nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db' +nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein' ``` +## Accounts + +The app is multi-tenant: each person signs in and sees only their own puppy's +events, profile and photos. + +- **Sessions.** Passwords are hashed with bcrypt; login mints a random session + token stored (hashed) in the `sessions` table and set as an `HttpOnly` cookie. + `/api/*` (except `login`/`register`/`logout`) requires a valid session. +- **Registration is invite-gated.** Sign-up requires the shared secret passed via + `-invite-code` / `PUPPY_INVITE_CODE`. With no code set, registration is + disabled (existing accounts can still log in). Share the code with whoever you + want to give an account. +- **First account adopts existing data.** When accounts are introduced on a DB + that already had single-tenant data (or that imported a legacy `events.json`), + the first account to register inherits all of it — events, profile and photos. +- **Serve over HTTPS in production.** Session cookies are only marked `Secure` + when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords + aren't sent in the clear. + ## Use it on NixOS In your system flake: @@ -78,6 +104,11 @@ In your system flake: enable = true; port = 8080; openFirewall = true; + # Registration secret, kept out of the Nix store. The file holds: + # PUPPY_INVITE_CODE=some-shared-secret + inviteCodeFile = "/run/secrets/puppy-invite-code"; + # Enable once you terminate TLS in front of the service. + secureCookies = false; }; } ]; @@ -92,5 +123,7 @@ alongside it under `photos/`). ## Notes -- No auth. Intended for a home LAN. If exposing publicly, terminate TLS and - authenticate with a reverse proxy in front (Caddy / nginx / Tailscale Funnel). +- Accounts gate access, but there is no built-in TLS. If exposing publicly, + terminate TLS with a reverse proxy in front (Caddy / nginx / Tailscale Funnel) + and set `secureCookies = true`. Without HTTPS, passwords and session cookies + travel in the clear. diff --git a/flake.nix b/flake.nix index 5108df4..0da1ed8 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,7 @@ pname = "puppy-tracker-server"; version = "0.2.0"; src = ./server; - vendorHash = "sha256-fqXpr9fV1jeT7503uAjb4jjNPlXfESFnXr7/Uc83d4o="; + vendorHash = "sha256-z9Kf7i4WfLAHmceRi8T42+uMitjxEzr0pmOn+STpsAU="; # Pure-Go build for a tiny static binary. env.CGO_ENABLED = "0"; ldflags = [ "-s" "-w" ]; diff --git a/module.nix b/module.nix index 41a2402..97b9809 100644 --- a/module.nix +++ b/module.nix @@ -27,6 +27,28 @@ in description = "Whether to open the configured port in the firewall."; }; + inviteCodeFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/puppy-invite-code"; + description = '' + Path to an EnvironmentFile containing the shared registration secret as + `PUPPY_INVITE_CODE=...`. Kept out of the Nix store so the code stays + secret. When null, registration is disabled (existing accounts can still + log in). + ''; + }; + + secureCookies = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Mark session cookies Secure. Enable once the service is reached over + HTTPS (e.g. behind a TLS-terminating reverse proxy); leave off for plain + HTTP on a LAN, or browsers will drop the cookie and logins won't stick. + ''; + }; + package = lib.mkOption { type = lib.types.package; default = serverPkg; @@ -49,12 +71,16 @@ in after = [ "network.target" ]; serviceConfig = { - ExecStart = lib.concatStringsSep " " [ + ExecStart = lib.concatStringsSep " " ([ "${cfg.package}/bin/puppy-tracker-server" "-addr ${cfg.address}:${toString cfg.port}" "-static ${cfg.staticPackage}/share/puppy-tracker" "-data /var/lib/puppy-tracker/puppy.db" - ]; + ] ++ lib.optional cfg.secureCookies "-secure-cookies"); + + # Invite code (registration secret) is read from an env file kept out of + # the store, exposed to the server as PUPPY_INVITE_CODE. + EnvironmentFile = lib.mkIf (cfg.inviteCodeFile != null) cfg.inviteCodeFile; DynamicUser = true; StateDirectory = "puppy-tracker"; diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..cf1a258 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,383 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + sessionCookie = "puppy_session" + sessionValidity = 30 * 24 * time.Hour +) + +// ctxKey is an unexported type so our context values can't collide with any +// set elsewhere. +type ctxKey int + +const userIDKey ctxKey = 0 + +// User is the public shape returned to clients — never the password hash. +type User struct { + ID string `json:"id"` + Email string `json:"email"` +} + +// Auth owns everything account-related: the users/sessions tables, the shared +// invite code required to register, and whether session cookies are marked +// Secure (on behind TLS/a proxy). photosDir is needed so the first account can +// adopt legacy flat-layout photos. +type Auth struct { + db *sql.DB + inviteCode string + secure bool + photosDir string +} + +func newAuth(db *sql.DB, inviteCode string, secure bool, photosDir string) *Auth { + return &Auth{db: db, inviteCode: inviteCode, secure: secure, photosDir: photosDir} +} + +// ---------- users & sessions ---------- + +func newID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand failing is unrecoverable + } + // RFC-4122-ish v4 layout; good enough as an opaque unique id. + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + + hex.EncodeToString(b[10:16]) +} + +// hashToken stores only the hash of a session token, so a leaked database can't +// be used to impersonate live sessions. +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func (a *Auth) userCount() (int, error) { + var n int + err := a.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +var errEmailTaken = errors.New("email already registered") + +func (a *Auth) createUser(email, password string) (User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return User{}, err + } + u := User{ID: newID(), Email: email} + _, err = a.db.Exec( + `INSERT INTO users (id, email, password, created) VALUES (?, ?, ?, ?)`, + u.ID, email, string(hash), time.Now().UnixMilli()) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return User{}, errEmailTaken + } + return User{}, err + } + return u, nil +} + +// verify returns the user for the given credentials, or ok=false if the email +// is unknown or the password is wrong (indistinguishable to the caller). +func (a *Auth) verify(email, password string) (User, bool) { + var u User + var hash string + err := a.db.QueryRow( + `SELECT id, email, password FROM users WHERE email = ? COLLATE NOCASE`, email, + ).Scan(&u.ID, &u.Email, &hash) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + log.Printf("verify: %v", err) + } + return User{}, false + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + return User{}, false + } + return u, true +} + +// startSession mints a token, stores its hash, and returns the raw token for +// the cookie. +func (a *Auth) startSession(userID string) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + now := time.Now() + _, err := a.db.Exec( + `INSERT INTO sessions (token, user_id, created, expires) VALUES (?, ?, ?, ?)`, + hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli()) + if err != nil { + return "", err + } + return token, nil +} + +// userForToken resolves a raw cookie token to a user id, honouring expiry. +func (a *Auth) userForToken(token string) (string, bool) { + if token == "" { + return "", false + } + var userID string + var expires int64 + err := a.db.QueryRow( + `SELECT user_id, expires FROM sessions WHERE token = ?`, hashToken(token), + ).Scan(&userID, &expires) + if err != nil || time.Now().UnixMilli() > expires { + return "", false + } + return userID, true +} + +func (a *Auth) endSession(token string) { + if token == "" { + return + } + if _, err := a.db.Exec(`DELETE FROM sessions WHERE token = ?`, hashToken(token)); err != nil { + log.Printf("endSession: %v", err) + } +} + +// adopt gives every ownerless row (legacy single-tenant data) to userID, and +// moves legacy flat-layout photos into that user's photo directory. Called once, +// when the very first account registers. +func (a *Auth) adopt(userID string) error { + if _, err := a.db.Exec(`UPDATE events SET user_id = ? WHERE user_id = ''`, userID); err != nil { + return err + } + if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil { + return err + } + return a.adoptPhotos(userID) +} + +// adoptPhotos moves any *.jpg sitting directly in photosDir (the pre-accounts +// flat layout) into photosDir//. +func (a *Auth) adoptPhotos(userID string) error { + entries, err := os.ReadDir(a.photosDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + dstDir := filepath.Join(a.photosDir, userID) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jpg") { + continue + } + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return err + } + if err := os.Rename( + filepath.Join(a.photosDir, e.Name()), + filepath.Join(dstDir, e.Name()), + ); err != nil { + return err + } + } + return nil +} + +// ---------- cookies & middleware ---------- + +func (a *Auth) setCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: token, + Path: "/", + HttpOnly: true, + Secure: a.secure, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(sessionValidity), + }) +} + +func (a *Auth) clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + HttpOnly: true, + Secure: a.secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func cookieToken(r *http.Request) string { + c, err := r.Cookie(sessionCookie) + if err != nil { + return "" + } + return c.Value +} + +// requireUser wraps a handler so it only runs for an authenticated request, +// stashing the user id in the context. Unauthenticated calls get a 401 that the +// client uses as its cue to show the login screen. +func (a *Auth) requireUser(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := a.userForToken(cookieToken(r)) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID))) + } +} + +// userID returns the authenticated user's id; only valid inside a requireUser +// handler. +func userID(r *http.Request) string { + id, _ := r.Context().Value(userIDKey).(string) + return id +} + +// ---------- handlers ---------- + +type credentials struct { + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` +} + +func writeUser(w http.ResponseWriter, u User) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(u) +} + +func (a *Auth) handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if a.inviteCode == "" { + http.Error(w, "registration disabled", http.StatusForbidden) + return + } + var c credentials + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + // Constant-time compare so a wrong invite code can't be timed out. + if subtle.ConstantTimeCompare([]byte(c.Invite), []byte(a.inviteCode)) != 1 { + http.Error(w, "invalid invite code", http.StatusForbidden) + return + } + email := strings.TrimSpace(strings.ToLower(c.Email)) + if !strings.Contains(email, "@") || len(email) > 200 { + http.Error(w, "invalid email", http.StatusBadRequest) + return + } + if len(c.Password) < 8 || len(c.Password) > 200 { + http.Error(w, "password must be at least 8 characters", http.StatusBadRequest) + return + } + + // Whether this is the first account decides adoption of legacy data. Check + // before insert; the users table has no other writer during registration. + first, err := a.userCount() + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + u, err := a.createUser(email, c.Password) + if errors.Is(err, errEmailTaken) { + http.Error(w, "email already registered", http.StatusConflict) + return + } + if err != nil { + log.Printf("register: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if first == 0 { + if err := a.adopt(u.ID); err != nil { + log.Printf("adopt legacy data: %v", err) + // Non-fatal: the account exists; legacy data just stays ownerless. + } + } + a.issue(w, u) +} + +func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var c credentials + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + u, ok := a.verify(strings.TrimSpace(strings.ToLower(c.Email)), c.Password) + if !ok { + http.Error(w, "invalid email or password", http.StatusUnauthorized) + return + } + a.issue(w, u) +} + +// issue starts a session, sets the cookie, and returns the user. +func (a *Auth) issue(w http.ResponseWriter, u User) { + token, err := a.startSession(u.ID) + if err != nil { + log.Printf("start session: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + a.setCookie(w, token) + writeUser(w, u) +} + +func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + a.endSession(cookieToken(r)) + a.clearCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +// handleMe reports the current account. Wrapped in requireUser, so reaching it +// means the session is valid. +func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) { + var u User + err := a.db.QueryRow( + `SELECT id, email FROM users WHERE id = ?`, userID(r), + ).Scan(&u.ID, &u.Email) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + writeUser(w, u) +} diff --git a/server/go.mod b/server/go.mod index bdf8719..0baa3c3 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,7 +2,10 @@ module puppy-tracker go 1.25.0 -require modernc.org/sqlite v1.53.0 +require ( + golang.org/x/crypto v0.54.0 + modernc.org/sqlite v1.53.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect @@ -10,7 +13,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/server/go.sum b/server/go.sum index b054032..5456856 100644 --- a/server/go.sum +++ b/server/go.sum @@ -12,13 +12,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= diff --git a/server/main.go b/server/main.go index 9d53de6..8bf3111 100644 --- a/server/main.go +++ b/server/main.go @@ -54,12 +54,12 @@ func newConfigStore(db *sql.DB) *ConfigStore { return &ConfigStore{db: db} } -func (cs *ConfigStore) get() Config { +func (cs *ConfigStore) get(userID string) Config { var c Config - // The profile lives in a single row (id = 1). A missing row is the - // pre-configuration state, so a zero-value Config is the right answer. + // One profile row per user. A missing row is the pre-configuration state, + // so a zero-value Config is the right answer. err := cs.db.QueryRow( - `SELECT name, birthday, updated FROM config WHERE id = 1`, + `SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID, ).Scan(&c.Name, &c.Birthday, &c.UpdatedAt) if err != nil && !errors.Is(err, sql.ErrNoRows) { log.Printf("config get: %v", err) @@ -67,22 +67,22 @@ func (cs *ConfigStore) get() Config { return c } -// merge applies an incoming config with last-write-wins by UpdatedAt and -// returns the resulting stored config (which the caller sends back). -func (cs *ConfigStore) merge(in Config) (Config, error) { +// merge applies an incoming config for one user with last-write-wins by +// UpdatedAt and returns the resulting stored config (which the caller sends back). +func (cs *ConfigStore) merge(userID string, in Config) (Config, error) { // The upsert's WHERE clause enforces last-write-wins: the incoming row only // replaces the stored one when it is strictly newer. _, err := cs.db.Exec(` - INSERT INTO config (id, name, birthday, updated) - VALUES (1, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET + INSERT INTO config (user_id, name, birthday, updated) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET name = excluded.name, birthday = excluded.birthday, updated = excluded.updated WHERE excluded.updated > config.updated`, - in.Name, in.Birthday, in.UpdatedAt) + userID, in.Name, in.Birthday, in.UpdatedAt) if err != nil { return Config{}, err } - return cs.get(), nil + return cs.get(userID), nil } type Store struct { @@ -93,9 +93,10 @@ func newStore(db *sql.DB) *Store { return &Store{db: db} } -// sync merges client events into the store using last-write-wins by UpdatedAt, -// then returns the full merged set (including tombstones, which must propagate). -func (s *Store) sync(client []Event) ([]Event, error) { +// sync merges one user's client events into the store using last-write-wins by +// UpdatedAt, then returns that user's full merged set (tombstones included, as +// they must propagate). +func (s *Store) sync(userID string, client []Event) ([]Event, error) { tx, err := s.db.Begin() if err != nil { return nil, err @@ -104,14 +105,18 @@ func (s *Store) sync(client []Event) ([]Event, error) { // The WHERE clause on the upsert is the last-write-wins rule: an incoming // event only overwrites the stored one when its updatedAt is strictly newer. + // The `events.user_id = excluded.user_id` guard means one user can never + // clobber another's row even if a client forges a colliding event ID — + // the row stays put and, because reads are scoped, stays invisible to them. stmt, err := tx.Prepare(` - INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, at = excluded.at, note = excluded.note, photo_id = excluded.photo_id, weight = excluded.weight, updated = excluded.updated, deleted = excluded.deleted - WHERE excluded.updated > events.updated`) + WHERE excluded.updated > events.updated + AND events.user_id = excluded.user_id`) if err != nil { return nil, err } @@ -122,7 +127,7 @@ func (s *Store) sync(client []Event) ([]Event, error) { continue } if _, err := stmt.Exec( - ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, + ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID, ); err != nil { return nil, err } @@ -130,13 +135,14 @@ func (s *Store) sync(client []Event) ([]Event, error) { if err := tx.Commit(); err != nil { return nil, err } - return s.all() + return s.all(userID) } -// all returns every stored event, tombstones included. -func (s *Store) all() ([]Event, error) { +// all returns one user's events, tombstones included. +func (s *Store) all(userID string) ([]Event, error) { rows, err := s.db.Query( - `SELECT id, type, at, note, photo_id, weight, updated, deleted FROM events`) + `SELECT id, type, at, note, photo_id, weight, updated, deleted + FROM events WHERE user_id = ?`, userID) if err != nil { return nil, err } @@ -175,6 +181,9 @@ func openDB(path string) (*sql.DB, error) { return nil, err } } + // Fresh-install schema. Every row is scoped to a user_id; the empty string + // is the "ownerless" bucket that legacy single-tenant data lands in until + // the first account adopts it (see Auth.adopt). schema := ` CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, @@ -184,21 +193,105 @@ func openDB(path string) (*sql.DB, error) { photo_id TEXT NOT NULL DEFAULT '', weight REAL NOT NULL DEFAULT 0, updated INTEGER NOT NULL DEFAULT 0, - deleted INTEGER NOT NULL DEFAULT 0 + deleted INTEGER NOT NULL DEFAULT 0, + user_id TEXT NOT NULL DEFAULT '' ); + CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id); CREATE TABLE IF NOT EXISTS config ( - id INTEGER PRIMARY KEY CHECK (id = 1), + user_id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', birthday TEXT NOT NULL DEFAULT '', updated INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + created INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created INTEGER NOT NULL, + expires INTEGER NOT NULL );` if _, err := db.Exec(schema); err != nil { db.Close() return nil, err } + if err := migrateSchema(db); err != nil { + db.Close() + return nil, err + } return db, nil } +// migrateSchema upgrades a single-tenant database (from before accounts existed) +// in place: it adds events.user_id and rewrites the config table from its +// old single-row (id = 1) shape to one keyed by user_id. Pre-accounts data ends +// up ownerless (user_id = ”), ready for the first account to adopt. It is a +// no-op on a fresh DB, where openDB already created the current schema. +func migrateSchema(db *sql.DB) error { + has, err := columnExists(db, "events", "user_id") + if err != nil { + return err + } + if !has { + if _, err := db.Exec(`ALTER TABLE events ADD COLUMN user_id TEXT NOT NULL DEFAULT ''`); err != nil { + return err + } + if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id)`); err != nil { + return err + } + } + oldConfig, err := columnExists(db, "config", "id") + if err != nil { + return err + } + if oldConfig { + // Rebuild config keyed by user_id, moving the lone id=1 row into the + // ownerless bucket. + stmts := []string{ + `ALTER TABLE config RENAME TO config_old`, + `CREATE TABLE config ( + user_id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + birthday TEXT NOT NULL DEFAULT '', + updated INTEGER NOT NULL DEFAULT 0 + )`, + `INSERT INTO config (user_id, name, birthday, updated) + SELECT '', name, birthday, updated FROM config_old WHERE id = 1`, + `DROP TABLE config_old`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + return err + } + } + } + return nil +} + +// columnExists reports whether the given table has a column of the given name. +// A missing table reports false (no error), which is what fresh installs want. +func columnExists(db *sql.DB, table, col string) (bool, error) { + rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return false, err + } + if name == col { + return true, nil + } + } + return false, rows.Err() +} + // migrateJSON imports a pre-SQLite events.json / config.json sitting in dataDir // into an otherwise-empty database, then renames each file to *.imported so the // import runs exactly once. It is a no-op when the DB already holds data or the @@ -232,8 +325,10 @@ func importEvents(db *sql.DB, path string) error { if err != nil && !errors.Is(err, io.EOF) { return err } + // Imported as ownerless (user_id = ""); the first account to register adopts + // them. Mirrors how in-place schema migration parks legacy rows. store := newStore(db) - if _, err := store.sync(evs); err != nil { + if _, err := store.sync("", evs); err != nil { return err } log.Printf("migrated %d events from %s", len(evs), path) @@ -266,7 +361,8 @@ func importConfig(db *sql.DB, path string) error { // Nothing meaningful to import; leave config empty. return os.Rename(path, path+".imported") } - if _, err := newConfigStore(db).merge(c); err != nil { + // Ownerless until the first account adopts it (see importEvents). + if _, err := newConfigStore(db).merge("", c); err != nil { return err } log.Printf("migrated config from %s", path) @@ -292,6 +388,10 @@ func main() { addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)") dataPath := flag.String("data", "puppy.db", "path to SQLite database file") staticDir := flag.String("static", "", "directory of static files to serve") + inviteCode := flag.String("invite-code", os.Getenv("PUPPY_INVITE_CODE"), + "shared secret required to register (env PUPPY_INVITE_CODE); empty disables registration") + secureCookies := flag.Bool("secure-cookies", false, + "mark session cookies Secure (enable when served over HTTPS / behind a TLS proxy)") flag.Parse() db, err := openDB(*dataPath) @@ -313,9 +413,19 @@ func main() { log.Fatalf("mkdir photos: %v", err) } + auth := newAuth(db, *inviteCode, *secureCookies, photosDir) + if *inviteCode == "" { + log.Print("WARNING: no invite code set — registration is disabled (set -invite-code / PUPPY_INVITE_CODE)") + } + mux := http.NewServeMux() - mux.HandleFunc("/api/events/sync", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/register", auth.handleRegister) + mux.HandleFunc("/api/login", auth.handleLogin) + mux.HandleFunc("/api/logout", auth.handleLogout) + mux.HandleFunc("/api/me", auth.requireUser(auth.handleMe)) + + mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -325,7 +435,7 @@ func main() { http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) return } - merged, err := store.sync(req.Events) + merged, err := store.sync(userID(r), req.Events) if err != nil { log.Printf("sync: %v", err) http.Error(w, "server error", http.StatusInternalServerError) @@ -337,11 +447,11 @@ func main() { Events: merged, ServerNow: time.Now().UnixMilli(), }) - }) + })) - // GET /api/config — return the shared puppy profile. + // GET /api/config — return the caller's puppy profile. // PUT /api/config — update it (last-write-wins by updatedAt). - mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { writeConfig := func(c Config) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -349,7 +459,7 @@ func main() { } switch r.Method { case http.MethodGet: - writeConfig(configStore.get()) + writeConfig(configStore.get(userID(r))) case http.MethodPut, http.MethodPost: var in Config if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil { @@ -364,7 +474,7 @@ func main() { http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest) return } - merged, err := configStore.merge(in) + merged, err := configStore.merge(userID(r), in) if err != nil { log.Printf("config save: %v", err) http.Error(w, "server error", http.StatusInternalServerError) @@ -374,7 +484,7 @@ func main() { default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } - }) + })) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) @@ -383,7 +493,7 @@ func main() { // POST /api/photos — multipart upload with form fields `id` (UUID) and // `file` (JPEG). The client generates the ID so the event referencing // the photo can be written before the upload round-trips. - mux.HandleFunc("/api/photos", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/photos", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -405,7 +515,14 @@ func main() { } defer file.Close() - dstPath := filepath.Join(photosDir, id+".jpg") + // Photos live under the owner's directory so a photo can only ever be + // read back by the account that uploaded it. + userDir := filepath.Join(photosDir, userID(r)) + if err := os.MkdirAll(userDir, 0o755); err != nil { + http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError) + return + } + dstPath := filepath.Join(userDir, id+".jpg") tmp := dstPath + ".tmp" dst, err := os.Create(tmp) if err != nil { @@ -432,11 +549,12 @@ func main() { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) - }) + })) - // GET /api/photos/ — serves the JPEG. Photos are immutable per ID - // so we mark them as long-lived; both browser and SW can cache freely. - mux.HandleFunc("/api/photos/", func(w http.ResponseWriter, r *http.Request) { + // GET /api/photos/ — serves the caller's own JPEG. Photos are immutable + // per ID so we mark them as long-lived; the private cache keeps them per + // user. Serving only from the caller's directory makes ownership implicit. + mux.HandleFunc("/api/photos/", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -446,7 +564,7 @@ func main() { http.Error(w, "invalid id", http.StatusBadRequest) return } - path := filepath.Join(photosDir, id+".jpg") + path := filepath.Join(photosDir, userID(r), id+".jpg") f, err := os.Open(path) if err != nil { http.NotFound(w, r) @@ -459,9 +577,9 @@ func main() { return } w.Header().Set("Content-Type", "image/jpeg") - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") http.ServeContent(w, r, path, stat.ModTime(), f) - }) + })) if *staticDir != "" { fileServer := http.FileServer(http.Dir(*staticDir)) diff --git a/src/app.js b/src/app.js index 71a6896..58990b3 100644 --- a/src/app.js +++ b/src/app.js @@ -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(); + })(); })(); diff --git a/src/index.html b/src/index.html index 4fe4b4d..1ea2727 100644 --- a/src/index.html +++ b/src/index.html @@ -11,6 +11,33 @@ + + + +
diff --git a/src/style.css b/src/style.css index 26d88c3..73bd737 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } diff --git a/src/sw.js b/src/sw.js index d92b650..2153ee7 100644 --- a/src/sw.js +++ b/src/sw.js @@ -1,4 +1,4 @@ -const CACHE = "puppy-tracker-v5"; +const CACHE = "puppy-tracker-v6"; const PHOTO_CACHE = "puppy-tracker-photos-v1"; const ASSETS = [ "./",