mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
0c5ec04fac
* feat(admin): extend user list with aggregations + sort/filter (TASK-1544)
GET /admin/users now returns per-user workspace_count, storage_bytes,
last_write_at, and a computed status pill (disabled / no-workspace /
inactive / active, with documented precedence). Adds sort and filter
knobs so the table can scale beyond the existing fixed offset/limit.
Store layer:
- AdminUserSearchParams gains Role, Sort, Order, ActiveWithinDays,
HasWorkspaces, Disabled. Pointer types where tri-state ("no filter"
vs. "filter to false") matters.
- AdminUserListEntry wraps models.User with WorkspaceCount, StorageBytes,
Status — returned in AdminUserSearchResult.Users.
- SearchUsers SQL rewritten: LEFT JOIN against grouped subqueries so
one user owning N workspaces with M attachments each still produces
exactly one row (no aggregation explosion). Both subqueries filter
deleted_at IS NULL to match WorkspaceStorageUsage's existing
definition. Allow-listed sort clause prevents injection.
- computeAdminUserStatus exported for unit tests; precedence locked in
by TestComputeAdminUserStatus.
- TestSearchUsersAggregations covers workspace_count + storage_bytes +
status across a three-user fixture and each new filter/sort knob.
Model + scanner:
- models.User gains LastWriteAt. userColumns + scanUser updated; the
legacy callers (GetUser, ListUsers, etc.) inherit the new field for
free via the shared scanner.
Handler:
- handleAdminListUsers accepts the new params: role, disabled,
has_workspaces, active_within_days, sort, order. Tri-state bools
only fire when the query param is present. Response now embeds
workspace_count / storage_bytes / last_write_at / status.
Part of PLAN-1542. Frontend consumption lands in T1548 (cheap columns)
and T1549 (sort/filter UI).
* fix: address Codex review on TASK-1544
- Tri-state bool parsing in handler now uses strconv.ParseBool — accepts
the canonical truthy/falsy variants ("True"/"TRUE"/"t"/"1" and the
parallel falses), and silently ignores garbage values rather than
treating them as false. Closes the "disabled=TRUE silently means
enabled-only" surprise.
- SearchUsers count query no longer joins the storage aggregation when
HasWorkspaces isn't an active filter. The page query still needs both
joins (the row carries the data), but a typical "give me a count"
call no longer scans every live attachment. The workspace_count join
remains conditional on HasWorkspaces filtering.
Status threshold (>30d vs >=30d): the documented spec and impl both say
">30d" — no change.
131 lines
5.1 KiB
Go
131 lines
5.1 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
)
|
|
|
|
// User represents a registered user in the system.
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"` // Unique handle; empty until set
|
|
Name string `json:"name"`
|
|
PasswordHash string `json:"-"` // Never serialized
|
|
Role string `json:"role"` // "admin" or "member"
|
|
AvatarURL string `json:"avatar_url,omitempty"`
|
|
TOTPSecret string `json:"-"` // Never serialized
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
RecoveryCodes string `json:"-"` // Never serialized
|
|
Plan string `json:"plan"` // "free", "pro", or "self-hosted"
|
|
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
|
|
StripeCustomerID string `json:"-"` // Never serialized
|
|
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
|
|
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
|
|
PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash)
|
|
DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled
|
|
LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write)
|
|
LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// IsDisabled returns true if the user account has been disabled.
|
|
func (u *User) IsDisabled() bool {
|
|
return u.DisabledAt != ""
|
|
}
|
|
|
|
// GetOAuthProviders parses the JSON oauth_providers field into a string slice.
|
|
func (u *User) GetOAuthProviders() []string {
|
|
if u.OAuthProviders == "" {
|
|
return nil
|
|
}
|
|
var providers []string
|
|
if err := json.Unmarshal([]byte(u.OAuthProviders), &providers); err != nil {
|
|
return nil
|
|
}
|
|
return providers
|
|
}
|
|
|
|
// HasOAuthProvider returns true if the user has linked the given provider.
|
|
func (u *User) HasOAuthProvider(provider string) bool {
|
|
for _, p := range u.GetOAuthProviders() {
|
|
if p == provider {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasPassword returns true if the user has explicitly set a password that
|
|
// they can sign in with. OAuth-only users have a random placeholder hash
|
|
// stored in PasswordHash which can't actually be used to log in, so this
|
|
// bit is tracked separately from PasswordHash being non-empty.
|
|
func (u *User) HasPassword() bool {
|
|
return u.PasswordSet
|
|
}
|
|
|
|
// UserCreate is the input for registering a new user.
|
|
type UserCreate struct {
|
|
Email string `json:"email"`
|
|
Username string `json:"username,omitempty"` // Optional; auto-generated if empty
|
|
Name string `json:"name"`
|
|
Password string `json:"password"` // Plaintext, will be hashed
|
|
Role string `json:"role,omitempty"` // Defaults to "member"
|
|
}
|
|
|
|
// UserUpdate is the input for updating user profile fields.
|
|
type UserUpdate struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Username *string `json:"username,omitempty"`
|
|
Password *string `json:"password,omitempty"` // Plaintext, will be hashed
|
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
|
}
|
|
|
|
// Session represents a database-backed authentication session.
|
|
type Session struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
TokenHash string `json:"-"` // Never serialized
|
|
DeviceInfo string `json:"device_info,omitempty"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// WorkspaceInvitation represents a pending invitation to join a workspace.
|
|
type WorkspaceInvitation struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
InvitedBy string `json:"invited_by"`
|
|
Code string `json:"code"`
|
|
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// IsExpired reports whether an invitation is past its expiration window.
|
|
// Invitations created before the expires_at migration (nil ExpiresAt) are
|
|
// treated as non-expiring so existing codes don't break on upgrade.
|
|
func (inv *WorkspaceInvitation) IsExpired() bool {
|
|
if inv == nil || inv.ExpiresAt == nil {
|
|
return false
|
|
}
|
|
return time.Now().UTC().After(*inv.ExpiresAt)
|
|
}
|
|
|
|
// WorkspaceMember represents a user's membership in a workspace.
|
|
type WorkspaceMember struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"` // "owner", "editor", or "viewer"
|
|
CollectionAccess string `json:"collection_access"` // "all" or "specific"
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Populated by joins (not stored)
|
|
UserName string `json:"user_name,omitempty"`
|
|
UserEmail string `json:"user_email,omitempty"`
|
|
UserUsername string `json:"user_username,omitempty"`
|
|
}
|