From b3af1acd075e535e86114eea5dd24e4ee2eab28c Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 13 Apr 2026 22:19:41 -0400 Subject: [PATCH] feat: add last active tracking for users (#105) * feat: add last active tracking for users Track when users were last active via a throttled update (once per 5 minutes) in the auth middleware. Adds last_active_at column, displays relative time in admin user list with full timestamp on hover. * fix: bound last-active goroutine with 3s context timeout Use a short-lived context for the background TouchUserActivity write so it gets cancelled under DB pressure, preventing goroutine/connection buildup from unbounded background work. --- internal/models/user.go | 1 + internal/server/handlers_admin_users.go | 3 ++ internal/server/middleware_auth.go | 8 +++++ .../migrations/041_user_last_active_at.sql | 3 ++ .../pgmigrations/021_user_last_active_at.sql | 3 ++ internal/store/users.go | 32 +++++++++++++++++-- web/src/lib/stores/admin.svelte.ts | 1 + web/src/routes/console/admin/+page.svelte | 25 ++++++++++++++- 8 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 internal/store/migrations/041_user_last_active_at.sql create mode 100644 internal/store/pgmigrations/021_user_last_active_at.sql diff --git a/internal/models/user.go b/internal/models/user.go index 2d05f682..b090a162 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -23,6 +23,7 @@ type User struct { PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"] DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled + LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/server/handlers_admin_users.go b/internal/server/handlers_admin_users.go index 1b66fbd9..52a912cf 100644 --- a/internal/server/handlers_admin_users.go +++ b/internal/server/handlers_admin_users.go @@ -56,6 +56,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { PlanOverrides string `json:"plan_overrides,omitempty"` TOTPEnabled bool `json:"totp_enabled"` DisabledAt string `json:"disabled_at,omitempty"` + LastActiveAt string `json:"last_active_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -73,6 +74,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { PlanOverrides: u.PlanOverrides, TOTPEnabled: u.TOTPEnabled, DisabledAt: u.DisabledAt, + LastActiveAt: u.LastActiveAt, CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) @@ -120,6 +122,7 @@ func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) { "plan_overrides": user.PlanOverrides, "totp_enabled": user.TOTPEnabled, "disabled_at": user.DisabledAt, + "last_active_at": user.LastActiveAt, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, "workspace_count": len(workspaces), diff --git a/internal/server/middleware_auth.go b/internal/server/middleware_auth.go index f574d3dc..7672d691 100644 --- a/internal/server/middleware_auth.go +++ b/internal/server/middleware_auth.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/xarmian/pad/internal/models" @@ -212,6 +213,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.") return } + // Use a short-lived context so the write is cancelled if the DB is slow, + // preventing goroutine/connection buildup under load. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + s.store.TouchUserActivity(ctx, user.ID) + }() next.ServeHTTP(w, r) return } diff --git a/internal/store/migrations/041_user_last_active_at.sql b/internal/store/migrations/041_user_last_active_at.sql new file mode 100644 index 00000000..76757527 --- /dev/null +++ b/internal/store/migrations/041_user_last_active_at.sql @@ -0,0 +1,3 @@ +-- Track when users were last active on the platform. +-- NULL = never active (or created before this migration). +ALTER TABLE users ADD COLUMN last_active_at TEXT DEFAULT NULL; diff --git a/internal/store/pgmigrations/021_user_last_active_at.sql b/internal/store/pgmigrations/021_user_last_active_at.sql new file mode 100644 index 00000000..17a5cec0 --- /dev/null +++ b/internal/store/pgmigrations/021_user_last_active_at.sql @@ -0,0 +1,3 @@ +-- Track when users were last active on the platform. +-- NULL = never active (or created before this migration). +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TEXT DEFAULT NULL; diff --git a/internal/store/users.go b/internal/store/users.go index 8bea365d..a99b89dd 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -1,6 +1,7 @@ package store import ( + "context" "crypto/rand" "crypto/sha256" "database/sql" @@ -9,6 +10,7 @@ import ( "fmt" "regexp" "strings" + "time" "github.com/xarmian/pad/internal/models" "golang.org/x/crypto/bcrypt" @@ -19,7 +21,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`) const bcryptCost = 12 // user SELECT columns — used by all user queries. -const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, disabled_at, created_at, updated_at` +const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, disabled_at, last_active_at, created_at, updated_at` // scanUser scans a user row into a User struct. // Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after @@ -28,16 +30,19 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error) var u models.User var createdAt, updatedAt string - var disabledAt sql.NullString + var disabledAt, lastActiveAt sql.NullString err := row.Scan( &u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, &u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes, &u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders, - &disabledAt, &createdAt, &updatedAt, + &disabledAt, &lastActiveAt, &createdAt, &updatedAt, ) if disabledAt.Valid { u.DisabledAt = disabledAt.String } + if lastActiveAt.Valid { + u.LastActiveAt = lastActiveAt.String + } if err == sql.ErrNoRows { return nil, nil } @@ -404,6 +409,27 @@ func (s *Store) RemoveOAuthProvider(userID, provider string) error { // ErrLastAdmin is returned when a role change would leave zero admins. var ErrLastAdmin = fmt.Errorf("cannot demote the last admin") +// TouchUserActivity updates last_active_at for a user, throttled to avoid +// write amplification. Only writes if the stored value is older than 5 minutes. +// Accepts a context so callers can bound the write duration. +func (s *Store) TouchUserActivity(ctx context.Context, userID string) { + ts := now() + // Conditional update: only write if NULL or older than 5 minutes + s.db.ExecContext(ctx, s.q(` + UPDATE users SET last_active_at = ? + WHERE id = ? AND (last_active_at IS NULL OR last_active_at < ?) + `), ts, userID, throttleTime(ts)) +} + +// throttleTime returns a timestamp 5 minutes before the given RFC3339 time string. +func throttleTime(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return ts + } + return t.Add(-5 * time.Minute).Format(time.RFC3339) +} + // DisableUser soft-disables a user account by setting disabled_at. func (s *Store) DisableUser(userID string) error { _, err := s.db.Exec(s.q(`UPDATE users SET disabled_at = ?, updated_at = ? WHERE id = ?`), diff --git a/web/src/lib/stores/admin.svelte.ts b/web/src/lib/stores/admin.svelte.ts index 875e0bce..3da80460 100644 --- a/web/src/lib/stores/admin.svelte.ts +++ b/web/src/lib/stores/admin.svelte.ts @@ -15,6 +15,7 @@ export interface AdminUser { plan_overrides: Record | null; totp_enabled: boolean; disabled_at: string | null; + last_active_at: string | null; created_at: string; } diff --git a/web/src/routes/console/admin/+page.svelte b/web/src/routes/console/admin/+page.svelte index 0188ffbd..554af42b 100644 --- a/web/src/routes/console/admin/+page.svelte +++ b/web/src/routes/console/admin/+page.svelte @@ -153,6 +153,21 @@ } } + function relativeTime(dateStr: string | null): string { + if (!dateStr) return 'Never'; + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const seconds = Math.floor((now - then) / 1000); + if (seconds < 60) return 'Just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return formatDate(dateStr); + } + onMount(() => { loadUsers(); }); @@ -188,6 +203,7 @@ Role Email Plan + Last Active Created @@ -216,11 +232,14 @@ >{user.plan || 'free'} + {relativeTime(user.last_active_at)} {formatDate(user.created_at)} {#if selectedId === user.id} - +
@@ -460,6 +479,10 @@ .date-cell { white-space: nowrap; } + .date-cell.muted { + color: var(--text-muted); + font-size: 0.8rem; + } .badge { padding: 2px var(--space-2); border-radius: var(--radius-sm);