diff --git a/internal/models/user.go b/internal/models/user.go index e93ea7ef..99790f4f 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -6,6 +6,7 @@ import "time" 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" @@ -20,14 +21,16 @@ type User struct { // 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" + 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"` } @@ -62,6 +65,7 @@ type WorkspaceMember struct { CreatedAt time.Time `json:"created_at"` // Populated by joins (not stored) - UserName string `json:"user_name,omitempty"` - UserEmail string `json:"user_email,omitempty"` + UserName string `json:"user_name,omitempty"` + UserEmail string `json:"user_email,omitempty"` + UserUsername string `json:"user_username,omitempty"` } diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 41febc03..632df2c9 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -33,6 +33,7 @@ func sessionUserPayload(user *models.User) map[string]interface{} { return map[string]interface{}{ "id": user.ID, "email": user.Email, + "username": user.Username, "name": user.Name, "role": user.Role, "totp_enabled": user.TOTPEnabled, @@ -445,6 +446,7 @@ func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ "id": user.ID, "email": user.Email, + "username": user.Username, "name": user.Name, "role": user.Role, "avatar_url": user.AvatarURL, @@ -532,6 +534,7 @@ func (s *Server) handleUpdateCurrentUser(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]interface{}{ "id": updated.ID, "email": updated.Email, + "username": updated.Username, "name": updated.Name, "role": updated.Role, "avatar_url": updated.AvatarURL, @@ -663,10 +666,11 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "user": map[string]interface{}{ - "id": user.ID, - "email": user.Email, - "name": user.Name, - "role": user.Role, + "id": user.ID, + "email": user.Email, + "username": user.Username, + "name": user.Name, + "role": user.Role, }, "token": sessionToken, }) diff --git a/internal/store/migrations/029_username.sql b/internal/store/migrations/029_username.sql new file mode 100644 index 00000000..f35ad78b --- /dev/null +++ b/internal/store/migrations/029_username.sql @@ -0,0 +1,5 @@ +-- Add username column to users table. +-- Nullable initially (empty string = not yet set). +-- TASK-482 will backfill existing users and add NOT NULL constraint. +ALTER TABLE users ADD COLUMN username TEXT DEFAULT ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username) WHERE username != ''; diff --git a/internal/store/pgmigrations/009_username.sql b/internal/store/pgmigrations/009_username.sql new file mode 100644 index 00000000..fb3b7e5e --- /dev/null +++ b/internal/store/pgmigrations/009_username.sql @@ -0,0 +1,5 @@ +-- Add username column to users table. +-- Nullable initially (empty string = not yet set). +-- TASK-482 will backfill existing users and add NOT NULL constraint. +ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT DEFAULT ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username) WHERE username != ''; diff --git a/internal/store/store.go b/internal/store/store.go index 8784d04f..15dd6785 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -145,6 +145,7 @@ func (s *Store) migrate() error { "026_session_binding.sql", "027_totp.sql", "028_workspace_sort_order.sql", + "029_username.sql", } for _, name := range migrations { @@ -197,6 +198,7 @@ func (s *Store) migratePostgres() error { "006_session_binding.sql", "007_totp.sql", "008_workspace_sort_order.sql", + "009_username.sql", } for _, name := range migrations { diff --git a/internal/store/users.go b/internal/store/users.go index 53281780..89761be5 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -14,7 +14,7 @@ import ( const bcryptCost = 12 // user SELECT columns — used by all user queries. -const userColumns = `id, email, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, created_at, updated_at` +const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, created_at, updated_at` // scanUser scans a user row into a User struct. func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error) { @@ -22,7 +22,7 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error) var createdAt, updatedAt string err := row.Scan( - &u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, + &u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, &u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes, &createdAt, &updatedAt, ) @@ -54,9 +54,9 @@ func (s *Store) CreateUser(input models.UserCreate) (*models.User, error) { ts := now() _, err = s.db.Exec(s.q(` - INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Name), string(hash), role, ts, ts) + INSERT INTO users (id, email, username, name, password_hash, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, ts, ts) if err != nil { return nil, fmt.Errorf("insert user: %w", err) } @@ -83,6 +83,19 @@ func (s *Store) GetUserByEmail(email string) (*models.User, error) { return u, nil } +// GetUserByUsername retrieves a user by username (case-insensitive). +func (s *Store) GetUserByUsername(username string) (*models.User, error) { + username = strings.ToLower(strings.TrimSpace(username)) + if username == "" { + return nil, nil + } + u, err := scanUser(s.db.QueryRow(s.q(`SELECT `+userColumns+` FROM users WHERE LOWER(username) = ?`), username)) + if err != nil { + return nil, fmt.Errorf("get user by username: %w", err) + } + return u, nil +} + // UpdateUser updates mutable user fields. func (s *Store) UpdateUser(id string, input models.UserUpdate) (*models.User, error) { var sets []string @@ -92,6 +105,10 @@ func (s *Store) UpdateUser(id string, input models.UserUpdate) (*models.User, er sets = append(sets, "name = ?") args = append(args, strings.TrimSpace(*input.Name)) } + if input.Username != nil { + sets = append(sets, "username = ?") + args = append(args, strings.TrimSpace(*input.Username)) + } if input.Password != nil { hash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), bcryptCost) if err != nil { diff --git a/internal/store/workspace_members.go b/internal/store/workspace_members.go index ae851827..65809d5d 100644 --- a/internal/store/workspace_members.go +++ b/internal/store/workspace_members.go @@ -68,7 +68,7 @@ func (s *Store) GetWorkspaceMember(workspaceID, userID string) (*models.Workspac func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMember, error) { rows, err := s.db.Query(s.q(` SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at, - u.name, u.email + u.name, u.email, u.username FROM workspace_members wm JOIN users u ON u.id = wm.user_id WHERE wm.workspace_id = ? @@ -85,7 +85,7 @@ func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMemb var createdAt string if err := rows.Scan( &m.WorkspaceID, &m.UserID, &m.Role, &createdAt, - &m.UserName, &m.UserEmail, + &m.UserName, &m.UserEmail, &m.UserUsername, ); err != nil { return nil, fmt.Errorf("scan workspace member: %w", err) } diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index a8c50346..176ee091 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -105,11 +105,11 @@ export interface AuthSession { setup_required: boolean; setup_method?: 'local_cli' | 'docker_exec' | 'cloud'; auth_method: 'password' | 'cloud'; - user?: { id: string; email: string; name: string; role: string }; + user?: { id: string; email: string; username: string; name: string; role: string }; } export interface LoginResponse { - user?: { id: string; email: string; name: string; role: string }; + user?: { id: string; email: string; username: string; name: string; role: string }; token?: string; requires_2fa?: boolean; challenge_token?: string; @@ -530,12 +530,12 @@ export const api = { body: JSON.stringify({ email, password }) }), verify2FA: (challengeToken: string, code?: string, recoveryCode?: string) => - request<{ user: { id: string; email: string; name: string; role: string }; token: string }>('/auth/2fa/login-verify', { + request<{ user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/2fa/login-verify', { method: 'POST', body: JSON.stringify({ challenge_token: challengeToken, code: code || undefined, recovery_code: recoveryCode || undefined }) }), register: (email: string, name: string, password: string, invitation_code?: string) => - request<{ user: { id: string; email: string; name: string; role: string }; token: string }>('/auth/register', { + request<{ user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/register', { method: 'POST', body: JSON.stringify({ email, name, password, ...(invitation_code ? { invitation_code } : {}) }) }), @@ -546,7 +546,7 @@ export const api = { body: JSON.stringify({ email }) }), resetPassword: (token: string, password: string) => - request<{ ok: boolean; user: { id: string; email: string; name: string; role: string }; token: string }>('/auth/reset-password', { + request<{ ok: boolean; user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) }), diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index a30bc172..d4070fab 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -3,6 +3,7 @@ export interface User { id: string; email: string; + username: string; name: string; role: string; avatar_url?: string; @@ -12,6 +13,7 @@ export interface User { export interface UserProfileUpdate { name?: string; + username?: string; current_password?: string; new_password?: string; }