Files
xinmi/internal/types/user.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

289 lines
12 KiB
Go

package types
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"gorm.io/gorm"
)
// UserPreferences holds per-user preferences persisted server-side
// so they sync across devices/browsers. Fields are pointers so we can
// distinguish "client didn't send this key" (leave existing value alone)
// from "client explicitly set false" — the partial-update merge in
// UpdateUserPreferences relies on this.
//
// Adding a new preference key:
// 1. Add a *T field below + JSON tag (snake_case, must match the front-end key).
// 2. Extend the merge logic in service.UserService.UpdateUserPreferences.
// 3. Surface the new knob in the frontend settings store.
//
// No DB DDL is required — preferences is a single jsonb column.
type UserPreferences struct {
// LastActiveTenantID remembers the last workspace the user actively
// switched into, so a fresh login (new device, cleared browser, new
// refresh token) lands them back in that workspace instead of always
// bouncing to their home workspace. Written by the SPA's preferences
// PUT and by service-level SwitchTenant (including when switching
// home, which stores the home ID). Login / RefreshToken validate that
// the workspace still exists and the user still has an active membership
// (or CanAccessAllTenants) before honouring this preference; an
// invalid pointer is best-effort cleared and the user falls back to
// home. Refresh JWT claims have no tenant_id, so RefreshToken
// re-resolves from this field.
//
// nil = no preference (use user.TenantID, i.e. home)
// *0 = "clear preference" sentinel for the partial-update endpoint
// (UpdateUserPreferences turns this into nil). Otherwise treat
// a stored *0 the same as nil.
// *N = preferred workspace id.
LastActiveTenantID *uint64 `json:"last_active_tenant_id,omitempty"`
// OidcOnlyLogin is set server-side when an account is auto-provisioned
// via OIDC with a random password the user never received. The profile
// UI hides self-service password rotation until the user sets a known
// password via ChangePassword (which clears this flag).
OidcOnlyLogin *bool `json:"oidc_only_login,omitempty"`
}
// Value implements driver.Valuer so GORM persists UserPreferences as
// JSON text (Postgres jsonb column / SQLite TEXT). Empty struct serialises
// to "{}", matching the NOT NULL DEFAULT '{}' column constraint.
func (p UserPreferences) Value() (driver.Value, error) {
return json.Marshal(p)
}
// Scan implements sql.Scanner so GORM can hydrate UserPreferences back
// from the underlying column. Accept []byte (Postgres jsonb / SQLite blob)
// and string (some drivers hand TEXT as string) for portability.
func (p *UserPreferences) Scan(value interface{}) error {
if value == nil {
*p = UserPreferences{}
return nil
}
var data []byte
switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.New("UserPreferences.Scan: unsupported type")
}
if len(data) == 0 {
*p = UserPreferences{}
return nil
}
return json.Unmarshal(data, p)
}
// User represents a user in the system
type User struct {
// Unique identifier of the user
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// Username of the user
Username string `json:"username" gorm:"type:varchar(100);uniqueIndex;not null"`
// Email address of the user
Email string `json:"email" gorm:"type:varchar(255);uniqueIndex;not null"`
// Hashed password of the user
PasswordHash string `json:"-" gorm:"type:varchar(255);not null"`
// Avatar URL of the user
Avatar string `json:"avatar" gorm:"type:varchar(500)"`
// Workspace ID that the user belongs to
TenantID uint64 `json:"tenant_id" gorm:"index"`
// Whether the user is active
IsActive bool `json:"is_active" gorm:"default:true"`
// Whether the user can access all workspaces (cross-workspace access)
CanAccessAllTenants bool `json:"can_access_all_tenants" gorm:"default:false"`
// Whether the user is a system administrator (independent of workspace roles)
IsSystemAdmin bool `json:"is_system_admin" gorm:"default:false;index"`
// Per-user UI/feature preferences.
// Stored as JSON (jsonb on Postgres, TEXT on SQLite) via the
// driver.Valuer / sql.Scanner methods on UserPreferences.
Preferences UserPreferences `json:"preferences" gorm:"type:jsonb;not null;default:'{}'"`
// Creation time of the user
CreatedAt time.Time `json:"created_at"`
// Last updated time of the user
UpdatedAt time.Time `json:"updated_at"`
// Deletion time of the user
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
// Association relationship, not stored in the database
Tenant *Tenant `json:"tenant,omitempty" gorm:"foreignKey:TenantID"`
}
// AuthToken represents an authentication token
type AuthToken struct {
// Unique identifier of the token
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// User ID that owns this token
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
// Token value (JWT or other format)
Token string `json:"token" gorm:"type:text;not null"`
// Token type (access_token, refresh_token)
TokenType string `json:"token_type" gorm:"type:varchar(50);not null"`
// Token expiration time
ExpiresAt time.Time `json:"expires_at"`
// Whether the token is revoked
IsRevoked bool `json:"is_revoked" gorm:"default:false"`
// Creation time of the token
CreatedAt time.Time `json:"created_at"`
// Last updated time of the token
UpdatedAt time.Time `json:"updated_at"`
// Association relationship
User *User `json:"user,omitempty" gorm:"foreignKey:UserID"`
}
// LoginRequest represents a login request
type LoginRequest struct {
Username string `json:"username" binding:"omitempty,max=100"`
// Email remains available for clients using the original login API.
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"required"`
}
type OIDCAuthURLResponse struct {
Success bool `json:"success"`
ProviderDisplayName string `json:"provider_display_name,omitempty"`
AuthorizationURL string `json:"authorization_url,omitempty"`
State string `json:"state,omitempty"`
// Nonce is bound to an HttpOnly cookie on /auth/oidc/url and verified
// on callback; omitted from JSON so clients cannot replay it alone.
Nonce string `json:"-"`
}
type OIDCConfigResponse struct {
Success bool `json:"success"`
Enabled bool `json:"enabled"`
ProviderDisplayName string `json:"provider_display_name,omitempty"`
}
type OIDCCallbackResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *User `json:"user,omitempty"`
// Tenant carries the active tenant for the issued token. The field
// name is preserved for backward compatibility with existing frontend
// OIDC callback handling; LoginResponse uses ActiveTenant for the
// same data.
Tenant *Tenant `json:"tenant,omitempty"`
// Memberships mirrors LoginResponse.Memberships so the OIDC flow
// produces the same role information available to password logins.
// Always populated (length >= 1 for an authenticated user).
Memberships []Membership `json:"memberships"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
IsNewUser bool `json:"is_new_user,omitempty"`
}
type OIDCUserInfo struct {
Subject string `json:"subject,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
Claims map[string]interface{} `json:"claims,omitempty"`
}
// RegisterRequest represents a registration request
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=3"`
// TenantProvisioning is server-controlled registration context. It is
// deliberately excluded from JSON so a public caller cannot choose its
// own tenancy semantics. Empty preserves the historical behaviour and is
// treated as create_personal by UserService.Register.
TenantProvisioning TenantProvisioningMode `json:"-"`
}
// AdminCreateUserRequest is the payload for a SystemAdmin provisioning a
// new local user via POST /api/v1/system/admin/users/create.
//
// Password is optional: when absent (or null), the service generates a
// random one and returns it exactly once. Any provided value, the
// empty string included, is subject to the password policy.
type AdminCreateUserRequest struct {
Username string `json:"username" binding:"required,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
Password *string `json:"password"`
}
// TenantProvisioningMode controls what UserService.Register does after it
// has validated the identity fields. Joining an existing tenant is
// orchestrated by the invitation handler because the invitation token is the
// authority for the target tenant and role.
type TenantProvisioningMode string
const (
TenantProvisioningCreatePersonal TenantProvisioningMode = "create_personal"
TenantProvisioningTenantless TenantProvisioningMode = "tenantless"
)
func (m TenantProvisioningMode) IsValid() bool {
return m == TenantProvisioningCreatePersonal || m == TenantProvisioningTenantless
}
// LoginResponse represents a login response
type LoginResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *User `json:"user,omitempty"`
// ActiveTenant is the workspace whose ID is encoded in the issued JWT;
// future requests are scoped to it until the client calls /auth/switch-tenant.
// Defaults to the user's home workspace on a fresh login.
ActiveTenant *Tenant `json:"active_tenant,omitempty"`
// Memberships lists every workspace the user can authenticate into,
// along with their role in each. Always populated (length 1 for users
// who only belong to their home workspace) so frontends can render a
// workspace switcher without a follow-up request. Serialised without
// omitempty so the field is always present as a JSON array (possibly
// empty) — the "always populated" contract relies on the server side
// guaranteeing a non-nil slice.
Memberships []Membership `json:"memberships"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// RegisterResponse represents a registration response
type RegisterResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *User `json:"user,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
}
// UserInfo represents user information for API responses
type UserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar"`
TenantID uint64 `json:"tenant_id"`
IsActive bool `json:"is_active"`
CanAccessAllTenants bool `json:"can_access_all_tenants"`
IsSystemAdmin bool `json:"is_system_admin"`
Preferences UserPreferences `json:"preferences"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToUserInfo converts User to UserInfo (without sensitive data)
func (u *User) ToUserInfo() *UserInfo {
return &UserInfo{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Avatar: u.Avatar,
TenantID: u.TenantID,
IsActive: u.IsActive,
CanAccessAllTenants: u.CanAccessAllTenants,
IsSystemAdmin: u.IsSystemAdmin,
Preferences: u.Preferences,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}