stato intermedio
This commit is contained in:
parent
e0ef48f6fd
commit
b852f656d4
|
|
@ -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 l’architettura.
|
||||
Mantenere separazione MVC pulita.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ func Migrate(database *gorm.DB) error {
|
|||
&models.User{},
|
||||
&models.EmailVerificationToken{},
|
||||
&models.PasswordResetToken{},
|
||||
&models.UserProperties{},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2,6 +2,13 @@
|
|||
var STORAGE_KEY = "theme";
|
||||
var root = document.documentElement;
|
||||
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() {
|
||||
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) {
|
||||
var button = document.getElementById("themeToggle");
|
||||
if (!button) return;
|
||||
|
|
@ -43,6 +60,11 @@
|
|||
}
|
||||
|
||||
function applyInitialTheme() {
|
||||
if (isAuthenticated && (serverTheme === "dark" || serverTheme === "light")) {
|
||||
setStoredTheme(serverTheme);
|
||||
applyTheme(serverTheme);
|
||||
return;
|
||||
}
|
||||
var stored = getStoredTheme();
|
||||
applyTheme(stored || getPreferredTheme());
|
||||
}
|
||||
|
|
@ -51,6 +73,7 @@
|
|||
var next = currentTheme() === "dark" ? "light" : "dark";
|
||||
applyTheme(next);
|
||||
setStoredTheme(next);
|
||||
persistThemePreference(next);
|
||||
updateToggleState(next);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@
|
|||
|
||||
{{if .CurrentUser}}
|
||||
<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="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 font-semibold text-white">
|
||||
{{if .CurrentUser.Name}}{{printf "%.1s" .CurrentUser.Name}}{{else}}{{printf "%.1s" .CurrentUser.Email}}{{end}}
|
||||
<span class="inline-flex items-center justify-center">
|
||||
<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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{{define "users_modal"}}
|
||||
<div class="grid gap-3 text-sm text-gray-700 dark:text-gray-200 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200 ">
|
||||
<div class="py-2">
|
||||
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.id">ID</span>:
|
||||
<span>{{.User.ID}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-2">
|
||||
<span class="font-semibold text-gray-900 dark:text-white" data-i18n="table.role">Role</span>:
|
||||
{{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>
|
||||
|
|
@ -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>
|
||||
{{end}}
|
||||
</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>{{if .User.Name}}{{.User.Name}}{{else}}-{{end}}</span>
|
||||
</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>{{.User.Email}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-2">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="py-2">
|
||||
<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>
|
||||
</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}}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,34 @@
|
|||
</button>
|
||||
</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">
|
||||
<div class="md:col-span-2">
|
||||
<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="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>
|
||||
<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 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>
|
||||
<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 class="flex items-end">
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="{{.PageData.Sort}}">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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}}">
|
||||
<script src="/static/vendor/htmx.min.js"></script>
|
||||
<script src="/static/vendor/flowbite.js"></script>
|
||||
|
|
@ -30,6 +34,8 @@
|
|||
(function () {
|
||||
var DEFAULT_LANG = 'it';
|
||||
var STORAGE_KEY = 'tc_lang';
|
||||
var SERVER_LANG = {{printf "%q" .UserLang}};
|
||||
var IS_AUTHENTICATED = {{if .CurrentUser}}true{{else}}false{{end}};
|
||||
var dictionaries = {
|
||||
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',
|
||||
|
|
@ -96,9 +102,33 @@
|
|||
dictionaries.de_ch = Object.assign({}, dictionaries.de);
|
||||
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() {
|
||||
var serverLang = normalizeLang(SERVER_LANG);
|
||||
if (IS_AUTHENTICATED && isSupportedLang(serverLang)) {
|
||||
localStorage.setItem(STORAGE_KEY, serverLang);
|
||||
return serverLang;
|
||||
}
|
||||
var stored = localStorage.getItem(STORAGE_KEY);
|
||||
return dictionaries[stored] ? stored : DEFAULT_LANG;
|
||||
return isSupportedLang(stored) ? normalizeLang(stored) : DEFAULT_LANG;
|
||||
}
|
||||
|
||||
function t(key, lang) {
|
||||
|
|
@ -171,13 +201,8 @@
|
|||
};
|
||||
flag.src = flags[lang] || '/static/vendor/flags/it.svg';
|
||||
flag.alt = labels[lang] || 'Lingua';
|
||||
if (lang === 'de_ch' || lang === 'fr_ch') {
|
||||
flag.style.width = '32px';
|
||||
flag.style.height = '32px';
|
||||
} else {
|
||||
flag.style.width = '48px';
|
||||
flag.style.height = '32px';
|
||||
}
|
||||
flag.style.height = '22px';
|
||||
}
|
||||
|
||||
(root || document).querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
|
|
@ -196,7 +221,9 @@
|
|||
|
||||
document.querySelectorAll('[data-lang-select]').forEach(function (btn) {
|
||||
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);
|
||||
var dropdownInstance = window.FlowbiteInstances && window.FlowbiteInstances.getInstance
|
||||
? 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) {
|
||||
if (typeof window.initDropdowns === 'function') window.initDropdowns();
|
||||
if (typeof window.initModals === 'function') {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{{define "language_dropdown"}}
|
||||
<div class="relative flex items-center gap-2">
|
||||
<img id="lang-flag" class="rounded object-cover" src="/static/vendor/flags/it.svg" alt="Italiano" style="width:48px;height:32px;">
|
||||
<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">
|
||||
<div class="relative flex items-center gap-2 text-sm font-small text-gray-7000">
|
||||
<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-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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@
|
|||
|
||||
{{if .CurrentUser}}
|
||||
<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="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 font-semibold text-white">
|
||||
{{if .CurrentUser.Name}}{{printf "%.1s" .CurrentUser.Name}}{{else}}{{printf "%.1s" .CurrentUser.Email}}{{end}}
|
||||
<span class="inline-flex items-center justify-center">
|
||||
<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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -39,10 +39,12 @@
|
|||
|
||||
{{if .CurrentUser}}
|
||||
<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="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 font-semibold text-white">
|
||||
{{if .CurrentUser.Name}}{{printf "%.1s" .CurrentUser.Name}}{{else}}{{printf "%.1s" .CurrentUser.Email}}{{end}}
|
||||
<span class="inline-flex items-center justify-center">
|
||||
<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>
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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>
|
||||
|
||||
<form action="/login" method="post" class="space-y-5">
|
||||
<form hx-post="/login" class="space-y-5">
|
||||
<div>
|
||||
<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 />
|
||||
|
|
|
|||
Loading…
Reference in New Issue