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.
This commit is contained in:
xarmian
2026-04-13 22:19:41 -04:00
committed by GitHub
parent d968b551b7
commit b3af1acd07
8 changed files with 72 additions and 4 deletions
+1
View File
@@ -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"`
}
+3
View File
@@ -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),
+8
View File
@@ -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
}
@@ -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;
@@ -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;
+29 -3
View File
@@ -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 = ?`),
+1
View File
@@ -15,6 +15,7 @@ export interface AdminUser {
plan_overrides: Record<string, number> | null;
totp_enabled: boolean;
disabled_at: string | null;
last_active_at: string | null;
created_at: string;
}
+24 -1
View File
@@ -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 @@
<th>Role</th>
<th>Email</th>
<th>Plan</th>
<th>Last Active</th>
<th>Created</th>
</tr>
</thead>
@@ -216,11 +232,14 @@
>{user.plan || 'free'}</span
></td
>
<td class="date-cell muted"
title={user.last_active_at || ''}
>{relativeTime(user.last_active_at)}</td>
<td class="date-cell">{formatDate(user.created_at)}</td>
</tr>
{#if selectedId === user.id}
<tr class="edit-row">
<td colspan="5">
<td colspan="6">
<div class="edit-panel">
<div class="edit-field">
<label for="edit-role">Role</label>
@@ -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);