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

@ -1,288 +0,0 @@
# Progetto: GoFiber MVC + HTMX + Svelte UI Kit + GORM + AUTH + Role System + Template Separation
# OBIETTIVO
Implementare un progetto GoFiber MVC completo con:
- HTML server-rendered (html/template)
- HTMX per partial HTML
- Design System Svelte (Custom Elements)
- GORM + SQLite/Postgres selezionabile via .env
- Migrazioni + seed
- CORS
- AUTH completo (signup, login, logout, verify email, lost password, reset)
- Email transactional (SMTP + file sink in develop)
- Separazione template per:
- public (pagine accessibili senza login)
- private (solo utenti autenticati)
- admin (solo utenti role=admin)
Architettura server-first. Nessuna SPA.
---
# TEMPLATE DIRECTORY STRUCTURE (OBBLIGATORIA)
Strutturare /web/templates così:
/web/templates/
layout.html
/public/
home.html
login.html
signup.html
forgot_password.html
reset_password.html
verify_notice.html
/private/
dashboard.html
users/
index.html
_table.html
_modal.html
/admin/
dashboard.html
users.html
Il layout deve essere unico e includere:
- ui.css
- htmx.min.js
- ui.esm.js
---
# RUOLI UTENTE
Aggiungere campo Role nel model User:
- role string
- "user" default
- "admin"
Vincoli:
- Solo admin può accedere a /admin/*
- /private/* richiede autenticazione
- /public/* accessibile a tutti
---
# ROUTING CON GRUPPI
Configurare in main.go:
Public routes:
- GET /
- GET /login
- POST /login
- GET /signup
- POST /signup
- GET /forgot-password
- POST /forgot-password
- GET /reset-password
- POST /reset-password
- GET /verify-email
Private group (RequireAuth middleware):
- GET /dashboard
- GET /users
- GET /users/table
- GET /users/:id/modal
- POST /logout
Admin group (RequireAuth + RequireAdmin middleware):
- GET /admin
- GET /admin/users
---
# MIDDLEWARE
Implementare:
## RequireAuth
- verifica sessione
- se non autenticato → redirect /login
## RequireAdmin
- verifica user.Role == "admin"
- se non admin → 403 o redirect /dashboard
---
# DATABASE MODEL UPDATE
Aggiornare model User:
- ID uint
- Email string unique
- PasswordHash string
- EmailVerified bool
- Role string (default "user")
- CreatedAt
- UpdatedAt
Migrazioni devono includere nuovo campo Role.
Seed:
- In develop creare:
- admin@example.com (role=admin, email verified)
- user@example.com (role=user, email verified)
Password default esempio: "password"
---
# AUTH REQUIREMENTS (RIEPILOGO)
Signup:
- crea utente con role=user
- EmailVerified=false
- genera token verifica
- invia email o salva in sink
Login:
- verifica password
- verifica EmailVerified
- salva sessione con:
- user_id
- user_role
Logout:
- distrugge sessione
Verify email:
- valida token hash
- set EmailVerified=true
Forgot password:
- genera reset token
- invia/salva email
Reset password:
- aggiorna PasswordHash
- invalida token
---
# EMAIL TEMPLATE DIRECTORY
Creare:
/web/emails/templates/
verify_email.html
verify_email.txt
reset_password.html
reset_password.txt
In develop:
- salvare email in:
EMAIL_SINK_DIR
- nome file:
timestamp__type__to.eml
In prod:
- inviare SMTP
---
# DIRECTORY PROTEZIONE LOGICA
Controllers devono renderizzare template in base al path:
- c.Render("public/login", data)
- c.Render("private/users/index", data)
- c.Render("admin/dashboard", data)
Mai mischiare.
---
# LAYOUT
layout.html deve:
- rilevare se utente autenticato
- mostrare navbar differente:
- public: login/signup
- user: dashboard + logout
- admin: dashboard + admin + logout
Passare CurrentUser al template se autenticato.
---
# HTMX IN PRIVATE
/users:
- search con hx-get
- table partial in private/users/_table.html
- modal in private/users/_modal.html
---
# CORS
Configurato da .env
---
# BUILD HASH
Usare BUILD_HASH in query string per css/js.
---
# CRITERI DI ACCETTAZIONE
1) Template directory separata correttamente
2) Accesso diretto a /private/* senza login → redirect /login
3) Accesso a /admin/* senza role=admin → 403
4) Signup crea utente role=user
5) Seed crea admin role=admin
6) Navbar cambia in base a stato login
7) Email sink funziona in develop
8) DB driver selezionabile via .env
9) Migrazioni e seed eseguiti all'avvio
10) Nessuna logica di autorizzazione nel frontend
---
# STRUTTURA FINALE PROGETTO (SEMPLIFICATA)
/cmd/server
/internal
/config
/db
/models
/repo
/services
/auth
/mailer
/controllers
/web
/templates
/public
/private
/admin
/emails/templates
/static
/ui-kit
---
# NOTE PER CODEX
- Creare tutti i file mancanti
- Scrivere codice completo, compilabile
- Commentare le parti sicurezza (token hashing)
- Usare bcrypt
- Usare SHA256 per token hash
- Non salvare mai token in chiaro nel DB
- Creare helper per CurrentUser
- Applicare middleware correttamente
Non semplificare larchitettura.
Mantenere separazione MVC pulita.

View File

@ -208,3 +208,63 @@ func (ac *AuthController) ResetPassword(c *fiber.Ctx) error {
} }
return c.Redirect("/login") 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 { func (uc *UsersController) Modal(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64) id := strings.TrimSpace(c.Params("id"))
if err != nil || id == 0 { if id == "" {
return c.Status(fiber.StatusBadRequest).SendString("invalid user 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 { if err != nil {
return err return err
} }

View File

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

View File

@ -23,6 +23,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleAdmin, Role: models.RoleAdmin,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
}, },
{ {
Name: "Normal User", Name: "Normal User",
@ -30,6 +34,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "it",
Dark: false,
},
}, },
{ {
Name: "Demo One", Name: "Demo One",
@ -37,6 +45,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
}, },
{ {
Name: "Demo Two", Name: "Demo Two",
@ -44,6 +56,10 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
}, },
{ {
Name: "Demo Three", Name: "Demo Three",
@ -51,11 +67,20 @@ func Seed(database *gorm.DB) error {
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Properties: models.UserProperties{
Lang: "en",
Dark: true,
},
}, },
} }
for _, user := range seedUsers { 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 return err
} }
} }
@ -63,7 +88,7 @@ func Seed(database *gorm.DB) error {
return nil 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{ result := database.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}}, Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{ DoUpdates: clause.AssignmentColumns([]string{
@ -73,9 +98,26 @@ func upsertUser(database *gorm.DB, user models.User) error {
"password_hash", "password_hash",
"updated_at", "updated_at",
}), }),
}).Create(&user) }).Omit("Properties").Create(&user)
if result.Error != nil { 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 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) store, ok := c.Locals(contextStoreKey).(*session.Store)
if !ok || store == nil { if !ok || store == nil {
return errors.New("session store not available") return errors.New("session store not available")

View File

@ -2,7 +2,7 @@ package middleware
import ( import (
"fmt" "fmt"
"strconv" "strings"
"trustcontact/internal/models" "trustcontact/internal/models"
"trustcontact/internal/repo" "trustcontact/internal/repo"
@ -37,6 +37,21 @@ func CurrentUserMiddleware(store *session.Store, database *gorm.DB) fiber.Handle
c.Locals(contextUserKey, user) c.Locals(contextUserKey, user)
setTemplateData(c, "CurrentUser", 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() return c.Next()
} }
} }
@ -49,7 +64,7 @@ func CurrentUser(c *fiber.Ctx, store *session.Store, userRepo *repo.UserRepo) (*
uidRaw := sess.Get(sessionUserIDKey) uidRaw := sess.Get(sessionUserIDKey)
uid, ok := normalizeUserID(uidRaw) uid, ok := normalizeUserID(uidRaw)
if !ok || uid == 0 { if !ok || uid == "" {
return nil, nil return nil, nil
} }
@ -77,37 +92,16 @@ func CurrentUserFromContext(c *fiber.Ctx) (*models.User, bool) {
return user, true return user, true
} }
func normalizeUserID(v any) (uint, bool) { func normalizeUserID(v any) (string, bool) {
switch value := v.(type) { 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: case string:
parsed, err := strconv.ParseUint(value, 10, 64) trimmed := strings.TrimSpace(value)
if err != nil || parsed == 0 { if trimmed == "" {
return 0, false return "", false
} }
return uint(parsed), true return trimmed, true
default: 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.Post("/reset-password", authController.ResetPassword)
app.Get("/forbidden", authController.ShowForbidden) app.Get("/forbidden", authController.ShowForbidden)
app.Get("/welcome", httpmw.RequireAuth(), authController.ShowWelcome) 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 := app.Group("/private", httpmw.RequireAuth(), httpmw.RequireAdmin())
private.Get("/", func(c *fiber.Ctx) error { private.Get("/", func(c *fiber.Ctx) error {

View File

@ -1,21 +1,38 @@
package models package models
import "time" import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type EmailVerificationToken struct { type EmailVerificationToken struct {
ID uint `gorm:"primaryKey"` ID string `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"` UserID string `gorm:"not null;index"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"` TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null;index"` ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt 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 { type PasswordResetToken struct {
ID uint `gorm:"primaryKey"` ID string `gorm:"primaryKey"`
UserID uint `gorm:"not null;index"` UserID string `gorm:"not null;index"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"` TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"not null;index"` ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt 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 package models
import "time" import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const ( const (
RoleAdmin = "admin" RoleAdmin = "admin"
@ -8,12 +13,25 @@ const (
) )
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID string `gorm:"primaryKey"`
Name string `gorm:"size:120;index"` Name string `gorm:"size:120;index"`
Email string `gorm:"size:320;uniqueIndex;not null"` Email string `gorm:"size:320;uniqueIndex;not null"`
PasswordHash string `gorm:"size:255;not null"` PasswordHash string `gorm:"size:255;not null"`
EmailVerified bool `gorm:"not null;default:false"` EmailVerified bool `gorm:"not null;default:false"`
Role string `gorm:"size:32;index;not null;default:user"` Role string `gorm:"size:32;index;not null;default:user"`
Properties UserProperties `gorm:"foreignKey:UserId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt 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 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 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 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 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 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 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} 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 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil 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) { func (r *UserRepo) FindByEmail(email string) (*models.User, error) {
var user models.User 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
@ -54,18 +54,58 @@ func (r *UserRepo) Create(user *models.User) error {
return r.db.Create(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{}). return r.db.Model(&models.User{}).
Where("id = ?", userID). Where("id = ?", userID).
Update("email_verified", verified).Error 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{}). return r.db.Model(&models.User{}).
Where("id = ?", userID). Where("id = ?", userID).
Update("password_hash", passwordHash).Error 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) { func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) {
query := r.db.Model(&models.User{}) query := r.db.Model(&models.User{})
@ -92,8 +132,8 @@ func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) {
if pageSize <= 0 { if pageSize <= 0 {
pageSize = 10 pageSize = 10
} }
if pageSize > 100 { if pageSize > 500 {
pageSize = 100 pageSize = 500
} }
offset := (page - 1) * pageSize offset := (page - 1) * pageSize

View File

@ -22,8 +22,20 @@ var (
ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailNotVerified = errors.New("email not verified") ErrEmailNotVerified = errors.New("email not verified")
ErrInvalidOrExpiredToken = errors.New("invalid or expired token") 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 { type AuthService struct {
cfg *config.Config cfg *config.Config
users *repo.UserRepo users *repo.UserRepo
@ -187,6 +199,23 @@ func (s *AuthService) ResetPassword(token, newPassword string) error {
return s.resetTokens.DeleteByID(record.ID) 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 { func (s *AuthService) issueVerifyEmail(ctx context.Context, user *models.User) error {
plainToken, err := auth.NewToken() plainToken, err := auth.NewToken()
if err != nil { if err != nil {
@ -245,3 +274,17 @@ func (s *AuthService) sendResetEmail(ctx context.Context, user *models.User, pla
func normalizeEmail(email string) string { func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email)) 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 { if pageSize <= 0 {
pageSize = 10 pageSize = 10
} }
if pageSize > 100 { if pageSize > 500 {
pageSize = 100 pageSize = 500
} }
sort := normalizeSort(query.Sort) sort := normalizeSort(query.Sort)
@ -104,7 +104,7 @@ func (s *UsersService) List(query UsersQuery) (*UsersPage, error) {
}, nil }, nil
} }
func (s *UsersService) GetByID(id uint) (*models.User, error) { func (s *UsersService) GetByID(id string) (*models.User, error) {
return s.users.FindByID(id) return s.users.FindByID(id)
} }

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,13 @@
var STORAGE_KEY = "theme"; var STORAGE_KEY = "theme";
var root = document.documentElement; var root = document.documentElement;
var mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); var mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
var isAuthenticated = window.__TC_IS_AUTHENTICATED === true || window.__TC_IS_AUTHENTICATED === "true";
var serverTheme = normalizeTheme(window.__TC_SERVER_THEME);
function normalizeTheme(theme) {
if (!theme) return "";
return String(theme).trim().toLowerCase();
}
function getStoredTheme() { function getStoredTheme() {
var value = null; var value = null;
@ -33,6 +40,16 @@
} }
} }
function persistThemePreference(theme) {
if (!isAuthenticated) return;
fetch("/preferences/theme", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ theme: theme }),
}).catch(function () {});
}
function updateToggleState(theme) { function updateToggleState(theme) {
var button = document.getElementById("themeToggle"); var button = document.getElementById("themeToggle");
if (!button) return; if (!button) return;
@ -43,6 +60,11 @@
} }
function applyInitialTheme() { function applyInitialTheme() {
if (isAuthenticated && (serverTheme === "dark" || serverTheme === "light")) {
setStoredTheme(serverTheme);
applyTheme(serverTheme);
return;
}
var stored = getStoredTheme(); var stored = getStoredTheme();
applyTheme(stored || getPreferredTheme()); applyTheme(stored || getPreferredTheme());
} }
@ -51,6 +73,7 @@
var next = currentTheme() === "dark" ? "light" : "dark"; var next = currentTheme() === "dark" ? "light" : "dark";
applyTheme(next); applyTheme(next);
setStoredTheme(next); setStoredTheme(next);
persistThemePreference(next);
updateToggleState(next); updateToggleState(next);
}; };

View File

@ -31,10 +31,12 @@
{{if .CurrentUser}} {{if .CurrentUser}}
<div class="relative"> <div class="relative">
<button type="button" class="flex items-center rounded-full bg-gray-800 text-sm focus:ring-4 focus:ring-gray-300 dark:bg-gray-700 dark:focus:ring-gray-700" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom"> <button type="button" class="inline-flex h-8 items-center rounded-lg bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
<span class="sr-only" data-i18n="nav.open_user_menu">Apri menu utente</span> <span class="sr-only" data-i18n="nav.open_user_menu">Apri menu utente</span>
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 font-semibold text-white"> <span class="inline-flex items-center justify-center">
{{if .CurrentUser.Name}}{{printf "%.1s" .CurrentUser.Name}}{{else}}{{printf "%.1s" .CurrentUser.Email}}{{end}} <svg class="h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</span> </span>
</button> </button>
<div class="z-50 my-4 hidden w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800" id="user-dropdown"> <div class="z-50 my-4 hidden w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800" id="user-dropdown">

View File

@ -1,10 +1,10 @@
{{define "users_modal"}} {{define "users_modal"}}
<div class="grid gap-3 text-sm text-gray-700 dark:text-gray-200 sm:grid-cols-2"> <div class="text-sm text-gray-700 dark:text-gray-200 ">
<div> <div class="py-2">
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.id">ID</span>: <span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.id">ID</span>:
<span>{{.User.ID}}</span> <span>{{.User.ID}}</span>
</div> </div>
<div> <div class="py-2">
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.role">Role</span>: <span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.role">Role</span>:
{{if eq .User.Role "admin"}} {{if eq .User.Role "admin"}}
<span class="rounded-sm bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800" data-i18n="user.role_admin">admin</span> <span class="rounded-sm bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800" data-i18n="user.role_admin">admin</span>
@ -12,25 +12,22 @@
<span class="rounded-sm bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800" data-i18n="user.role_user">user</span> <span class="rounded-sm bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800" data-i18n="user.role_user">user</span>
{{end}} {{end}}
</div> </div>
<div class="sm:col-span-2"> <div class="py-2">
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.name">Name</span>: <span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.name">Name</span>:
<span>{{if .User.Name}}{{.User.Name}}{{else}}-{{end}}</span> <span>{{if .User.Name}}{{.User.Name}}{{else}}-{{end}}</span>
</div> </div>
<div class="sm:col-span-2"> <div class="py-2">
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.email">Email</span>: <span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.email">Email</span>:
<span>{{.User.Email}}</span> <span>{{.User.Email}}</span>
</div> </div>
<div> <div class="py-2">
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="user.verified">Verified</span>: <span class="font-semibold text-gray-900 dark:text-white" data-i18n="user.verified">Verified</span>:
<span>{{if .User.EmailVerified}}<span data-i18n="user.yes">yes</span>{{else}}<span data-i18n="user.no">no</span>{{end}}</span> <span>{{if .User.EmailVerified}}<span data-i18n="user.yes">yes</span>{{else}}<span data-i18n="user.no">no</span>{{end}}</span>
</div> </div>
<div> <div class="py-2">
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="user.created">Created</span>: <span class="font-semibold text-gray-900 dark:text-white" data-i18n="user.created">Created</span>:
<span data-localize-date="{{.User.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.User.CreatedAt}}</span> <span data-localize-date="{{.User.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.User.CreatedAt}}</span>
</div> </div>
</div> </div>
<div class="mt-5">
<button type="button" class="rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-800" data-modal-hide="userModal" data-i18n="users.close">Chiudi</button>
</div>
{{end}} {{end}}

View File

@ -10,16 +10,34 @@
</button> </button>
</div> </div>
<form id="usersFilters" class="grid gap-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800 md:grid-cols-4" hx-get="/admin/users/table" hx-target="#usersTableContainer" hx-swap="innerHTML"> <form id="usersFilters" class="flex gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800 md:grid-cols-4" hx-get="/admin/users/table" hx-target="#usersTableContainer" hx-swap="innerHTML">
<div class="md:col-span-2"> <div class="flex-auto">
<label for="users-q" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white" data-i18n="users.search">Search</label> <label for="users-q" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white" data-i18n="users.search">Search</label>
<input id="users-q" type="text" name="q" placeholder="Cerca nome o email" data-i18n-placeholder="users.search_placeholder" value="{{.PageData.Q}}" class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"> <input id="users-q" type="text" name="q" placeholder="Cerca nome o email" data-i18n-placeholder="users.search_placeholder" value="{{.PageData.Q}}" class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400">
</div> </div>
<div> <div style="max-width: 100px;" class="flex-none">
<label for="users-size" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white" data-i18n="users.page_size">Page size</label> <label for="users-size" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white" data-i18n="users.page_size">Page size</label>
<input id="users-size" type="number" name="pageSize" min="1" max="100" value="{{.PageData.PageSize}}" class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"> <div class="relative">
<input id="users-size-value" type="hidden" name="pageSize" value="{{.PageData.PageSize}}">
<button id="users-size-button" data-dropdown-toggle="users-size-dropdown" type="button" class="inline-flex w-full items-center justify-between rounded-lg border border-gray-300 bg-gray-50 px-3 py-2.5 text-sm text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<span id="users-size-label">{{.PageData.PageSize}}</span>
<svg class="ms-2 h-2.5 w-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="users-size-dropdown" class="z-50 mt-1 hidden w-full divide-y divide-gray-100 rounded-lg bg-white shadow-sm dark:divide-gray-600 dark:bg-gray-700">
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="users-size-button">
<li><button type="button" class="block w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600" data-page-size-option="5">5</button></li>
<li><button type="button" class="block w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600" data-page-size-option="10">10</button></li>
<li><button type="button" class="block w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600" data-page-size-option="20">20</button></li>
<li><button type="button" class="block w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600" data-page-size-option="50">50</button></li>
<li><button type="button" class="block w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600" data-page-size-option="100">100</button></li>
<li><button type="button" class="block w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600" data-page-size-option="500">500</button></li>
</ul>
</div>
</div>
</div> </div>
<div class="flex items-end"> <div style="max-width: 120px;" class="flex-auto self-end">
<button type="submit" class="w-full rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300" data-i18n="users.search_button">Cerca</button> <button type="submit" class="w-full rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300" data-i18n="users.search_button">Cerca</button>
</div> </div>
<input type="hidden" name="sort" value="{{.PageData.Sort}}"> <input type="hidden" name="sort" value="{{.PageData.Sort}}">

View File

@ -4,7 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title> <title>{{.Title}}</title>
<script src="/static/vendor/theme.js"></script> <script>
window.__TC_IS_AUTHENTICATED = {{if .CurrentUser}}true{{else}}false{{end}};
window.__TC_SERVER_THEME = {{printf "%q" .UserTheme}};
</script>
<script src="/static/vendor/theme.js?v={{.BuildHash}}"></script>
<link rel="stylesheet" href="/static/css/app.css?v={{.BuildHash}}"> <link rel="stylesheet" href="/static/css/app.css?v={{.BuildHash}}">
<script src="/static/vendor/htmx.min.js"></script> <script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/flowbite.js"></script> <script src="/static/vendor/flowbite.js"></script>
@ -30,6 +34,8 @@
(function () { (function () {
var DEFAULT_LANG = 'it'; var DEFAULT_LANG = 'it';
var STORAGE_KEY = 'tc_lang'; var STORAGE_KEY = 'tc_lang';
var SERVER_LANG = {{printf "%q" .UserLang}};
var IS_AUTHENTICATED = {{if .CurrentUser}}true{{else}}false{{end}};
var dictionaries = { var dictionaries = {
it: { it: {
'nav.open_main_menu': 'Apri menu principale', 'nav.open_user_menu': 'Apri menu utente', 'nav.dashboard': 'Dashboard', 'nav.users': 'Users', 'nav.admin': 'Admin', 'nav.login': 'Login', 'nav.signup': 'Signup', 'nav.logout': 'Logout', 'nav.open_main_menu': 'Apri menu principale', 'nav.open_user_menu': 'Apri menu utente', 'nav.dashboard': 'Dashboard', 'nav.users': 'Users', 'nav.admin': 'Admin', 'nav.login': 'Login', 'nav.signup': 'Signup', 'nav.logout': 'Logout',
@ -96,9 +102,33 @@
dictionaries.de_ch = Object.assign({}, dictionaries.de); dictionaries.de_ch = Object.assign({}, dictionaries.de);
dictionaries.fr_ch = Object.assign({}, dictionaries.fr); dictionaries.fr_ch = Object.assign({}, dictionaries.fr);
function normalizeLang(lang) {
if (!lang) return '';
return String(lang).trim().toLowerCase().replace('-', '_');
}
function isSupportedLang(lang) {
return !!dictionaries[normalizeLang(lang)];
}
function persistLangPreference(lang) {
if (!IS_AUTHENTICATED) return;
fetch('/preferences/lang', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ lang: lang })
}).catch(function () {});
}
function getLang() { function getLang() {
var serverLang = normalizeLang(SERVER_LANG);
if (IS_AUTHENTICATED && isSupportedLang(serverLang)) {
localStorage.setItem(STORAGE_KEY, serverLang);
return serverLang;
}
var stored = localStorage.getItem(STORAGE_KEY); var stored = localStorage.getItem(STORAGE_KEY);
return dictionaries[stored] ? stored : DEFAULT_LANG; return isSupportedLang(stored) ? normalizeLang(stored) : DEFAULT_LANG;
} }
function t(key, lang) { function t(key, lang) {
@ -171,13 +201,8 @@
}; };
flag.src = flags[lang] || '/static/vendor/flags/it.svg'; flag.src = flags[lang] || '/static/vendor/flags/it.svg';
flag.alt = labels[lang] || 'Lingua'; flag.alt = labels[lang] || 'Lingua';
if (lang === 'de_ch' || lang === 'fr_ch') { flag.style.width = '32px';
flag.style.width = '32px'; flag.style.height = '22px';
flag.style.height = '32px';
} else {
flag.style.width = '48px';
flag.style.height = '32px';
}
} }
(root || document).querySelectorAll('[data-i18n]').forEach(function (el) { (root || document).querySelectorAll('[data-i18n]').forEach(function (el) {
@ -196,7 +221,9 @@
document.querySelectorAll('[data-lang-select]').forEach(function (btn) { document.querySelectorAll('[data-lang-select]').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
localStorage.setItem(STORAGE_KEY, btn.getAttribute('data-lang-select')); var selectedLang = normalizeLang(btn.getAttribute('data-lang-select'));
localStorage.setItem(STORAGE_KEY, selectedLang);
persistLangPreference(selectedLang);
applyTranslations(document); applyTranslations(document);
var dropdownInstance = window.FlowbiteInstances && window.FlowbiteInstances.getInstance var dropdownInstance = window.FlowbiteInstances && window.FlowbiteInstances.getInstance
? window.FlowbiteInstances.getInstance('Dropdown', 'lang-dropdown') ? window.FlowbiteInstances.getInstance('Dropdown', 'lang-dropdown')
@ -212,6 +239,28 @@
}); });
}); });
document.querySelectorAll('[data-page-size-option]').forEach(function (btn) {
btn.addEventListener('click', function () {
var value = btn.getAttribute('data-page-size-option');
var input = document.getElementById('users-size-value');
var label = document.getElementById('users-size-label');
if (input) input.value = value;
if (label) label.textContent = value;
var dropdownInstance = window.FlowbiteInstances && window.FlowbiteInstances.getInstance
? window.FlowbiteInstances.getInstance('Dropdown', 'users-size-dropdown')
: null;
if (dropdownInstance && typeof dropdownInstance.hide === 'function') {
dropdownInstance.hide();
return;
}
var button = document.getElementById('users-size-button');
var dropdown = document.getElementById('users-size-dropdown');
if (dropdown) dropdown.classList.add('hidden');
if (button) button.setAttribute('aria-expanded', 'false');
});
});
function reinitFlowbiteComponents(target) { function reinitFlowbiteComponents(target) {
if (typeof window.initDropdowns === 'function') window.initDropdowns(); if (typeof window.initDropdowns === 'function') window.initDropdowns();
if (typeof window.initModals === 'function') { if (typeof window.initModals === 'function') {

View File

@ -1,7 +1,7 @@
{{define "language_dropdown"}} {{define "language_dropdown"}}
<div class="relative flex items-center gap-2"> <div class="relative flex items-center gap-2 text-sm font-small text-gray-7000">
<img id="lang-flag" class="rounded object-cover" src="/static/vendor/flags/it.svg" alt="Italiano" style="width:48px;height:32px;"> <img id="lang-flag" class="rounded object-cover dark:outline" src="/static/vendor/flags/it.svg" alt="Italiano" style="width:32px;height:22px;">
<button id="lang-menu-button" data-dropdown-toggle="lang-dropdown" type="button" class="inline-flex h-10 items-center rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700" aria-expanded="false"> <button id="lang-menu-button" data-dropdown-toggle="lang-dropdown" type="button" class="inline-flex h-8 items-center rounded-lg bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700" aria-expanded="false">
<span id="lang-current" class="inline-block min-w-[95px]">Italiano</span> <span id="lang-current" class="inline-block min-w-[95px]">Italiano</span>
</button> </button>
<div id="lang-dropdown" class="z-50 my-2 hidden w-40 list-none divide-y divide-gray-100 rounded-lg bg-white text-sm shadow-sm dark:divide-gray-700 dark:bg-gray-800"> <div id="lang-dropdown" class="z-50 my-2 hidden w-40 list-none divide-y divide-gray-100 rounded-lg bg-white text-sm shadow-sm dark:divide-gray-700 dark:bg-gray-800">

View File

@ -30,10 +30,12 @@
{{if .CurrentUser}} {{if .CurrentUser}}
<div class="relative"> <div class="relative">
<button type="button" class="flex items-center rounded-full bg-gray-800 text-sm focus:ring-4 focus:ring-gray-300 dark:bg-gray-700 dark:focus:ring-gray-700" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom"> <button type="button" class="inline-flex h-8 items-center rounded-lg bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
<span class="sr-only" data-i18n="nav.open_user_menu">Apri menu utente</span> <span class="sr-only" data-i18n="nav.open_user_menu">Apri menu utente</span>
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 font-semibold text-white"> <span class="inline-flex items-center justify-center">
{{if .CurrentUser.Name}}{{printf "%.1s" .CurrentUser.Name}}{{else}}{{printf "%.1s" .CurrentUser.Email}}{{end}} <svg class="h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</span> </span>
</button> </button>
<div class="z-50 my-4 hidden w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800" id="user-dropdown"> <div class="z-50 my-4 hidden w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800" id="user-dropdown">

View File

@ -39,10 +39,12 @@
{{if .CurrentUser}} {{if .CurrentUser}}
<div class="relative"> <div class="relative">
<button type="button" class="flex items-center rounded-full bg-gray-800 text-sm focus:ring-4 focus:ring-gray-300 dark:bg-gray-700 dark:focus:ring-gray-700" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom"> <button type="button" class="inline-flex h-8 items-center rounded-lg bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
<span class="sr-only" data-i18n="nav.open_user_menu">Apri menu utente</span> <span class="sr-only" data-i18n="nav.open_user_menu">Apri menu utente</span>
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 font-semibold text-white"> <span class="inline-flex items-center justify-center">
{{if .CurrentUser.Name}}{{printf "%.1s" .CurrentUser.Name}}{{else}}{{printf "%.1s" .CurrentUser.Email}}{{end}} <svg class="h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</span> </span>
</button> </button>
<div class="z-50 my-4 hidden w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800" id="user-dropdown"> <div class="z-50 my-4 hidden w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800" id="user-dropdown">

View File

@ -4,7 +4,7 @@
<h1 class="mb-1 text-2xl font-bold text-gray-900 dark:text-white" data-i18n="login.title">Login</h1> <h1 class="mb-1 text-2xl font-bold text-gray-900 dark:text-white" data-i18n="login.title">Login</h1>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400" data-i18n="login.subtitle">Accedi al tuo account.</p> <p class="mb-6 text-sm text-gray-500 dark:text-gray-400" data-i18n="login.subtitle">Accedi al tuo account.</p>
<form action="/login" method="post" class="space-y-5"> <form hx-post="/login" class="space-y-5">
<div> <div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white" data-i18n="form.email">Email</label> <label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white" data-i18n="form.email">Email</label>
<input id="email" type="text" name="email" value="{{.Email}}" class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" required /> <input id="email" type="text" name="email" value="{{.Email}}" class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" required />