- internal/services/users: Repo mit CRUD, bcrypt (cost 12), Upsert für setup-store-Admin-Migration, RecordLogin - internal/handlers/users: GET/POST/PUT /users, POST /users/:id/password, DELETE /users/:id; Schutz gegen Selbst-Löschung - auth.go Login: DB-Nutzer first, Fallback auf setup-store-Admin; bei erfolgreichem Fallback wird der Admin per Upsert in die DB migriert (kein manueller Eingriff nötig) - management-ui: /users-Seite mit Tabelle, Anlegen-, Bearbeiten-, Passwort-setzen- und Löschen-Modals; "You"-Badge für eigenen Account - Sidebar + Route + i18n (de/en) ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
4.9 KiB
Go
182 lines
4.9 KiB
Go
// Package users manages the admin users table (login accounts).
|
|
// Passwords are bcrypt'd; minimum cost is 12 (≈300ms on modern hw).
|
|
// The users table is the authoritative source for login; the legacy
|
|
// setup-store admin is migrated into this table on first login.
|
|
package users
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const passwordCost = 12
|
|
|
|
var (
|
|
ErrNotFound = errors.New("user not found")
|
|
ErrEmailTaken = errors.New("email already in use")
|
|
)
|
|
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Active bool `json:"active"`
|
|
LastLoginAt *time.Time `json:"last_login_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type Repo struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
|
|
|
|
const selectCols = `id, email, role, active, last_login_at, created_at, updated_at`
|
|
|
|
func scan(row pgx.Row) (User, error) {
|
|
var u User
|
|
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active,
|
|
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
|
|
return u, err
|
|
}
|
|
|
|
func (r *Repo) List(ctx context.Context) ([]User, error) {
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT `+selectCols+` FROM users ORDER BY created_at`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []User
|
|
for rows.Next() {
|
|
u, err := scan(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// FindByEmail returns the user + bcrypt hash. ErrNotFound if absent.
|
|
func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, error) {
|
|
var u User
|
|
var hash string
|
|
err := r.pool.QueryRow(ctx,
|
|
`SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`,
|
|
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active,
|
|
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return u, "", ErrNotFound
|
|
}
|
|
return u, hash, err
|
|
}
|
|
|
|
func (r *Repo) Count(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func (r *Repo) Create(ctx context.Context, email, password, role string, active bool) (User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
u, err := scan(r.pool.QueryRow(ctx,
|
|
`INSERT INTO users (email, password_hash, role, active)
|
|
VALUES ($1,$2,$3,$4)
|
|
RETURNING `+selectCols,
|
|
email, string(hash), role, active))
|
|
if isUnique(err) {
|
|
return User{}, ErrEmailTaken
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
// Upsert: insert or update — used to migrate the legacy setup-store admin
|
|
// into the DB on first successful login without clobbering an existing row.
|
|
func (r *Repo) Upsert(ctx context.Context, email, password, role string, active bool) (User, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
u, err := scan(r.pool.QueryRow(ctx,
|
|
`INSERT INTO users (email, password_hash, role, active)
|
|
VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT (email) DO NOTHING
|
|
RETURNING `+selectCols,
|
|
email, string(hash), role, active))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// row already existed — return what's there without overwriting
|
|
u, _, err = r.FindByEmail(ctx, email)
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
func (r *Repo) Update(ctx context.Context, id int64, email, role string, active bool) (User, error) {
|
|
u, err := scan(r.pool.QueryRow(ctx,
|
|
`UPDATE users SET email=$1, role=$2, active=$3, updated_at=NOW()
|
|
WHERE id=$4 RETURNING `+selectCols,
|
|
email, role, active, id))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return u, ErrNotFound
|
|
}
|
|
if isUnique(err) {
|
|
return u, ErrEmailTaken
|
|
}
|
|
return u, err
|
|
}
|
|
|
|
func (r *Repo) SetPassword(ctx context.Context, id int64, password string) error {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), passwordCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tag, err := r.pool.Exec(ctx,
|
|
`UPDATE users SET password_hash=$1, updated_at=NOW() WHERE id=$2`,
|
|
string(hash), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
|
tag, err := r.pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repo) RecordLogin(ctx context.Context, id int64) {
|
|
_, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id)
|
|
}
|
|
|
|
// VerifyPassword is a constant-time bcrypt compare.
|
|
func VerifyPassword(hash, password string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
|
}
|
|
|
|
func isUnique(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return strings.Contains(s, "23505") || strings.Contains(s, "unique")
|
|
}
|