feat: add username column to users table

Add username field to the user data model as the foundation for
the multi-user permissions system (PLAN-407, TASK-408).

- SQLite migration 029 and Postgres migration 009 add username column
  with partial unique index (WHERE username != '')
- User, UserCreate, UserUpdate Go structs updated
- Store: CreateUser, UpdateUser, scanUser, userColumns updated
- New GetUserByUsername store method (case-insensitive lookup)
- All auth handler JSON payloads include username field
- WorkspaceMember struct and ListWorkspaceMembers query include username
- TypeScript User type and API client inline types updated

Column is empty string by default; TASK-482 will backfill existing
users and TASK-409 will add validation/registration flow support.
This commit is contained in:
xarmian
2026-04-10 22:40:07 +00:00
parent 4326ab5e04
commit f80876a52e
9 changed files with 59 additions and 20 deletions
+8 -4
View File
@@ -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"`
}
+8 -4
View File
@@ -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,
})
@@ -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 != '';
@@ -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 != '';
+2
View File
@@ -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 {
+22 -5
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
+5 -5
View File
@@ -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 })
}),
+2
View File
@@ -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;
}