// Package users manages application accounts. package users import ( "context" "database/sql" "errors" "time" "golang.org/x/crypto/bcrypt" "inventree/internal/models" ) var ( ErrNotFound = errors.New("user not found") ErrUsernameRequired = errors.New("username is required") ErrUsernameTaken = errors.New("username is already taken") ErrInvalidRole = errors.New("role must be admin or user") ErrLastAdmin = errors.New("cannot remove the last enabled admin") ) type Store struct{ db *sql.DB } func NewStore(db *sql.DB) *Store { return &Store{db: db} } const timeLayout = "2006-01-02T15:04:05.000Z" func parseTime(s string) time.Time { t, _ := time.Parse(timeLayout, s) return t } func scan(row interface{ Scan(...any) error }) (*models.User, error) { var u models.User var created, updated string if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Enabled, &created, &updated); err != nil { return nil, err } u.CreatedAt = parseTime(created) u.UpdatedAt = parseTime(updated) return &u, nil } func (s *Store) List(ctx context.Context) ([]models.User, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE ASC`) if err != nil { return nil, err } defer rows.Close() var out []models.User for rows.Next() { u, err := scan(rows) if err != nil { return nil, err } out = append(out, *u) } return out, rows.Err() } func (s *Store) Get(ctx context.Context, id int64) (*models.User, error) { row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users WHERE id = ?`, id) u, err := scan(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return u, err } func (s *Store) GetByUsername(ctx context.Context, username string) (*models.User, error) { row := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash, role, enabled, created_at, updated_at FROM users WHERE username = ?`, username) u, err := scan(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return u, err } // Create adds a user with the given plaintext password (hashed with bcrypt). func (s *Store) Create(ctx context.Context, u *models.User, plaintextPassword string) error { if u.Username == "" { return ErrUsernameRequired } if u.Role != models.RoleAdmin && u.Role != models.RoleUser { return ErrInvalidRole } if _, err := s.GetByUsername(ctx, u.Username); err == nil { return ErrUsernameTaken } else if !errors.Is(err, ErrNotFound) { return err } hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost) if err != nil { return err } res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, role, enabled) VALUES (?, ?, ?, ?)`, u.Username, string(hash), u.Role, u.Enabled) if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } u.ID = id return nil } // Update changes username, role, and enabled state (not the password). func (s *Store) Update(ctx context.Context, u *models.User) error { if u.Username == "" { return ErrUsernameRequired } if u.Role != models.RoleAdmin && u.Role != models.RoleUser { return ErrInvalidRole } if existing, err := s.GetByUsername(ctx, u.Username); err == nil && existing.ID != u.ID { return ErrUsernameTaken } if u.Role != models.RoleAdmin || !u.Enabled { if err := s.assertNotLastAdmin(ctx, u.ID); err != nil { return err } } _, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, role = ?, enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, u.Username, u.Role, u.Enabled, u.ID) return err } func (s *Store) SetPassword(ctx context.Context, id int64, plaintextPassword string) error { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost) if err != nil { return err } _, err = s.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, string(hash), id) return err } func (s *Store) Delete(ctx context.Context, id int64) error { if err := s.assertNotLastAdmin(ctx, id); err != nil { return err } _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) return err } // assertNotLastAdmin returns ErrLastAdmin if id is currently an enabled // admin and is the only one remaining. func (s *Store) assertNotLastAdmin(ctx context.Context, id int64) error { u, err := s.Get(ctx, id) if err != nil { return err } if u.Role != models.RoleAdmin || !u.Enabled { return nil } var count int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1`).Scan(&count); err != nil { return err } if count <= 1 { return ErrLastAdmin } return nil } // Authenticate verifies a username/password pair and returns the user if // valid, enabled, and the password matches. func (s *Store) Authenticate(ctx context.Context, username, password string) (*models.User, error) { u, err := s.GetByUsername(ctx, username) if err != nil { return nil, err } if !u.Enabled { return nil, errors.New("account is disabled") } if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { return nil, errors.New("invalid credentials") } return u, nil }