mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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 = ?`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user