stato intermedio

This commit is contained in:
fabio
2026-03-01 14:36:26 +01:00
parent e0ef48f6fd
commit b852f656d4
25 changed files with 4230 additions and 390 deletions

View File

@@ -208,3 +208,63 @@ func (ac *AuthController) ResetPassword(c *fiber.Ctx) error {
}
return c.Redirect("/login")
}
func (ac *AuthController) UpdateLanguage(c *fiber.Ctx) error {
currentUser, ok := httpmw.CurrentUserFromContext(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
type langRequest struct {
Lang string `json:"lang" form:"lang"`
}
var req langRequest
if err := c.BodyParser(&req); err != nil {
req.Lang = c.FormValue("lang")
}
lang := services.NormalizeLanguage(req.Lang)
if !services.IsSupportedLanguage(lang) {
return c.Status(fiber.StatusBadRequest).SendString("invalid language")
}
if err := ac.authService.UpdateUserLanguage(currentUser.ID, lang); err != nil {
if errors.Is(err, services.ErrInvalidLanguage) {
return c.Status(fiber.StatusBadRequest).SendString("invalid language")
}
return c.Status(fiber.StatusInternalServerError).SendString("cannot update language")
}
return c.SendStatus(fiber.StatusNoContent)
}
func (ac *AuthController) UpdateTheme(c *fiber.Ctx) error {
currentUser, ok := httpmw.CurrentUserFromContext(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
type themeRequest struct {
Theme string `json:"theme" form:"theme"`
}
var req themeRequest
if err := c.BodyParser(&req); err != nil {
req.Theme = c.FormValue("theme")
}
theme := services.NormalizeTheme(req.Theme)
if theme != "dark" && theme != "light" {
return c.Status(fiber.StatusBadRequest).SendString("invalid theme")
}
if err := ac.authService.UpdateUserTheme(currentUser.ID, theme); err != nil {
if errors.Is(err, services.ErrInvalidTheme) {
return c.Status(fiber.StatusBadRequest).SendString("invalid theme")
}
return c.Status(fiber.StatusInternalServerError).SendString("cannot update theme")
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -68,12 +68,12 @@ func (uc *UsersController) Table(c *fiber.Ctx) error {
}
func (uc *UsersController) Modal(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
id := strings.TrimSpace(c.Params("id"))
if id == "" {
return c.Status(fiber.StatusBadRequest).SendString("invalid user id")
}
user, err := uc.usersService.GetByID(uint(id))
user, err := uc.usersService.GetByID(id)
if err != nil {
return err
}

View File

@@ -11,5 +11,6 @@ func Migrate(database *gorm.DB) error {
&models.User{},
&models.EmailVerificationToken{},
&models.PasswordResetToken{},
&models.UserProperties{},
)
}

View File

@@ -23,6 +23,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleAdmin,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
{
Name: "Normal User",
@@ -30,6 +34,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "it",
Dark: false,
},
},
{
Name: "Demo One",
@@ -37,6 +45,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
{
Name: "Demo Two",
@@ -44,6 +56,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
{
Name: "Demo Three",
@@ -51,11 +67,20 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser,
EmailVerified: true,
PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
},
}
for _, user := range seedUsers {
if err := upsertUser(database, user); err != nil {
userID, err := upsertUser(database, user)
if err != nil {
return err
}
user.Properties.UserId = userID
if err := upsertUserProperties(database, user.Properties, user.Email); err != nil {
return err
}
}
@@ -63,7 +88,7 @@ func Seed(database *gorm.DB) error {
return nil
}
func upsertUser(database *gorm.DB, user models.User) error {
func upsertUser(database *gorm.DB, user models.User) (string, error) {
result := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{
@@ -73,9 +98,26 @@ func upsertUser(database *gorm.DB, user models.User) error {
"password_hash",
"updated_at",
}),
}).Create(&user)
}).Omit("Properties").Create(&user)
if result.Error != nil {
return fmt.Errorf("seed user %s: %w", user.Email, result.Error)
return "", fmt.Errorf("seed user %s: %w", user.Email, result.Error)
}
var persisted models.User
if err := database.Select("id").Where("email = ?", user.Email).First(&persisted).Error; err != nil {
return "", fmt.Errorf("load seeded user %s: %w", user.Email, err)
}
return persisted.ID, nil
}
func upsertUserProperties(database *gorm.DB, props models.UserProperties, userEmail string) error {
result := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoNothing: true,
}).Create(&props)
if result.Error != nil {
return fmt.Errorf("seed user properties %s: %w", userEmail, result.Error)
}
return nil

View File

@@ -31,7 +31,7 @@ func RequireAdmin() fiber.Handler {
}
}
func SetSessionUserID(c *fiber.Ctx, userID uint) error {
func SetSessionUserID(c *fiber.Ctx, userID string) error {
store, ok := c.Locals(contextStoreKey).(*session.Store)
if !ok || store == nil {
return errors.New("session store not available")

View File

@@ -2,7 +2,7 @@ package middleware
import (
"fmt"
"strconv"
"strings"
"trustcontact/internal/models"
"trustcontact/internal/repo"
@@ -37,6 +37,21 @@ func CurrentUserMiddleware(store *session.Store, database *gorm.DB) fiber.Handle
c.Locals(contextUserKey, user)
setTemplateData(c, "CurrentUser", user)
if user != nil {
setTemplateData(c, "UserLang", strings.TrimSpace(user.Properties.Lang))
if user.Properties.UserId != "" {
if user.Properties.Dark {
setTemplateData(c, "UserTheme", "dark")
} else {
setTemplateData(c, "UserTheme", "light")
}
} else {
setTemplateData(c, "UserTheme", "")
}
} else {
setTemplateData(c, "UserLang", "")
setTemplateData(c, "UserTheme", "")
}
return c.Next()
}
}
@@ -49,7 +64,7 @@ func CurrentUser(c *fiber.Ctx, store *session.Store, userRepo *repo.UserRepo) (*
uidRaw := sess.Get(sessionUserIDKey)
uid, ok := normalizeUserID(uidRaw)
if !ok || uid == 0 {
if !ok || uid == "" {
return nil, nil
}
@@ -77,37 +92,16 @@ func CurrentUserFromContext(c *fiber.Ctx) (*models.User, bool) {
return user, true
}
func normalizeUserID(v any) (uint, bool) {
func normalizeUserID(v any) (string, bool) {
switch value := v.(type) {
case uint:
return value, true
case uint64:
return uint(value), true
case uint32:
return uint(value), true
case int:
if value <= 0 {
return 0, false
}
return uint(value), true
case int64:
if value <= 0 {
return 0, false
}
return uint(value), true
case int32:
if value <= 0 {
return 0, false
}
return uint(value), true
case string:
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil || parsed == 0 {
return 0, false
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", false
}
return uint(parsed), true
return trimmed, true
default:
return 0, false
return "", false
}
}

View File

@@ -50,6 +50,8 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg
app.Post("/reset-password", authController.ResetPassword)
app.Get("/forbidden", authController.ShowForbidden)
app.Get("/welcome", httpmw.RequireAuth(), authController.ShowWelcome)
app.Post("/preferences/lang", httpmw.RequireAuth(), authController.UpdateLanguage)
app.Post("/preferences/theme", httpmw.RequireAuth(), authController.UpdateTheme)
private := app.Group("/private", httpmw.RequireAuth(), httpmw.RequireAdmin())
private.Get("/", func(c *fiber.Ctx) error {

View File

@@ -1,21 +1,38 @@
package models
import "time"
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type EmailVerificationToken struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
ID string `gorm:"primaryKey"`
UserID string `gorm:"not null;index"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (token *EmailVerificationToken) BeforeCreate(tx *gorm.DB) (err error) {
// UUID version 4
token.ID = uuid.NewString()
return
}
type PasswordResetToken struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"`
ID string `gorm:"primaryKey"`
UserID string `gorm:"not null;index"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (token *PasswordResetToken) BeforeCreate(tx *gorm.DB) (err error) {
// UUID version 4
token.ID = uuid.NewString()
return
}

View File

@@ -1,6 +1,11 @@
package models
import "time"
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
RoleAdmin = "admin"
@@ -8,12 +13,25 @@ const (
)
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:120;index"`
Email string `gorm:"size:320;uniqueIndex;not null"`
PasswordHash string `gorm:"size:255;not null"`
EmailVerified bool `gorm:"not null;default:false"`
Role string `gorm:"size:32;index;not null;default:user"`
ID string `gorm:"primaryKey"`
Name string `gorm:"size:120;index"`
Email string `gorm:"size:320;uniqueIndex;not null"`
PasswordHash string `gorm:"size:255;not null"`
EmailVerified bool `gorm:"not null;default:false"`
Role string `gorm:"size:32;index;not null;default:user"`
Properties UserProperties `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (user *User) BeforeCreate(tx *gorm.DB) (err error) {
// UUID version 4
user.ID = uuid.NewString()
return
}
type UserProperties struct {
UserId string `json:"user_id" gorm:"uniqueIndex"`
Lang string `json:"lang"`
Dark bool `json:"dark"`
}

View File

@@ -33,10 +33,10 @@ func (r *EmailVerificationTokenRepo) FindValidByHash(tokenHash string, now time.
return &token, nil
}
func (r *EmailVerificationTokenRepo) DeleteByID(id uint) error {
func (r *EmailVerificationTokenRepo) DeleteByID(id string) error {
return r.db.Delete(&models.EmailVerificationToken{}, id).Error
}
func (r *EmailVerificationTokenRepo) DeleteByUserID(userID uint) error {
func (r *EmailVerificationTokenRepo) DeleteByUserID(userID string) error {
return r.db.Where("user_id = ?", userID).Delete(&models.EmailVerificationToken{}).Error
}

View File

@@ -33,10 +33,10 @@ func (r *PasswordResetTokenRepo) FindValidByHash(tokenHash string, now time.Time
return &token, nil
}
func (r *PasswordResetTokenRepo) DeleteByID(id uint) error {
func (r *PasswordResetTokenRepo) DeleteByID(id string) error {
return r.db.Delete(&models.PasswordResetToken{}, id).Error
}
func (r *PasswordResetTokenRepo) DeleteByUserID(userID uint) error {
func (r *PasswordResetTokenRepo) DeleteByUserID(userID string) error {
return r.db.Where("user_id = ?", userID).Delete(&models.PasswordResetToken{}).Error
}

View File

@@ -26,9 +26,9 @@ func NewUserRepo(db *gorm.DB) *UserRepo {
return &UserRepo{db: db}
}
func (r *UserRepo) FindByID(id uint) (*models.User, error) {
func (r *UserRepo) FindByID(id string) (*models.User, error) {
var user models.User
if err := r.db.First(&user, id).Error; err != nil {
if err := r.db.Preload("Properties").Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -40,7 +40,7 @@ func (r *UserRepo) FindByID(id uint) (*models.User, error) {
func (r *UserRepo) FindByEmail(email string) (*models.User, error) {
var user models.User
if err := r.db.Where("email = ?", email).First(&user).Error; err != nil {
if err := r.db.Preload("Properties").Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -54,18 +54,58 @@ func (r *UserRepo) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *UserRepo) SetEmailVerified(userID uint, verified bool) error {
func (r *UserRepo) SetEmailVerified(userID string, verified bool) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Update("email_verified", verified).Error
}
func (r *UserRepo) UpdatePasswordHash(userID uint, passwordHash string) error {
func (r *UserRepo) UpdatePasswordHash(userID string, passwordHash string) error {
return r.db.Model(&models.User{}).
Where("id = ?", userID).
Update("password_hash", passwordHash).Error
}
func (r *UserRepo) UpsertLanguagePreference(userID string, lang string) error {
var existing models.UserProperties
err := r.db.Where("user_id = ?", userID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
props := models.UserProperties{
UserId: userID,
Lang: lang,
}
return r.db.Create(&props).Error
}
return r.db.Model(&models.UserProperties{}).
Where("user_id = ?", userID).
Update("lang", lang).Error
}
func (r *UserRepo) UpsertDarkPreference(userID string, dark bool) error {
var existing models.UserProperties
err := r.db.Where("user_id = ?", userID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
props := models.UserProperties{
UserId: userID,
Dark: dark,
}
return r.db.Create(&props).Error
}
return r.db.Model(&models.UserProperties{}).
Where("user_id = ?", userID).
Update("dark", dark).Error
}
func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) {
query := r.db.Model(&models.User{})
@@ -92,8 +132,8 @@ func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) {
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
if pageSize > 500 {
pageSize = 500
}
offset := (page - 1) * pageSize

View File

@@ -22,8 +22,20 @@ var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailNotVerified = errors.New("email not verified")
ErrInvalidOrExpiredToken = errors.New("invalid or expired token")
ErrInvalidLanguage = errors.New("invalid language")
ErrInvalidTheme = errors.New("invalid theme")
)
var supportedLanguages = map[string]struct{}{
"it": {},
"en": {},
"en_us": {},
"de": {},
"fr": {},
"de_ch": {},
"fr_ch": {},
}
type AuthService struct {
cfg *config.Config
users *repo.UserRepo
@@ -187,6 +199,23 @@ func (s *AuthService) ResetPassword(token, newPassword string) error {
return s.resetTokens.DeleteByID(record.ID)
}
func (s *AuthService) UpdateUserLanguage(userID string, lang string) error {
normalized := NormalizeLanguage(lang)
if !IsSupportedLanguage(normalized) {
return ErrInvalidLanguage
}
return s.users.UpsertLanguagePreference(userID, normalized)
}
func (s *AuthService) UpdateUserTheme(userID string, theme string) error {
normalized := NormalizeTheme(theme)
if normalized != "dark" && normalized != "light" {
return ErrInvalidTheme
}
return s.users.UpsertDarkPreference(userID, normalized == "dark")
}
func (s *AuthService) issueVerifyEmail(ctx context.Context, user *models.User) error {
plainToken, err := auth.NewToken()
if err != nil {
@@ -245,3 +274,17 @@ func (s *AuthService) sendResetEmail(ctx context.Context, user *models.User, pla
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func NormalizeLanguage(lang string) string {
normalized := strings.ToLower(strings.TrimSpace(lang))
return strings.ReplaceAll(normalized, "-", "_")
}
func IsSupportedLanguage(lang string) bool {
_, ok := supportedLanguages[NormalizeLanguage(lang)]
return ok
}
func NormalizeTheme(theme string) string {
return strings.ToLower(strings.TrimSpace(theme))
}

View File

@@ -49,8 +49,8 @@ func (s *UsersService) List(query UsersQuery) (*UsersPage, error) {
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
if pageSize > 500 {
pageSize = 500
}
sort := normalizeSort(query.Sort)
@@ -104,7 +104,7 @@ func (s *UsersService) List(query UsersQuery) (*UsersPage, error) {
}, nil
}
func (s *UsersService) GetByID(id uint) (*models.User, error) {
func (s *UsersService) GetByID(id string) (*models.User, error) {
return s.users.FindByID(id)
}