This commit is contained in:
fabio 2026-02-22 17:51:25 +01:00
parent 036aadb09a
commit c60ff109a4
11 changed files with 444 additions and 1 deletions

View File

@ -0,0 +1,22 @@
Implementa modulo “users” sotto /web/templates/private/users.
Routes protette (RequireAuth):
- GET /users -> pagina con search + container tabella
- GET /users/table -> partial HTML tabella (htmx)
- GET /users/:id/modal -> partial HTML contenuto modal
Requisiti tabella:
- query params: q, sort (id|name|email whitelist), dir (asc|desc), page, pageSize
- server-driven paging/sort/search usando GORM (Count + Limit/Offset + Order)
- _table.html deve includere:
- header th cliccabili con hx-get (toggle dir)
- pager prev/next con hx-get
- bottone “Apri” che hx-get sul modal e hx-target="#userModal" hx-swap="innerHTML"
- apri modal via JS minimal: setAttribute('open','') dopo swap (o onclick)
Crea template:
- private/users/index.html
- private/users/_table.html
- private/users/_modal.html
Integra <ui-modal id="userModal"> nella index privata.

View File

@ -0,0 +1,125 @@
package controllers
import (
"fmt"
"html/template"
"path/filepath"
"strconv"
"strings"
httpmw "trustcontact/internal/http/middleware"
"trustcontact/internal/services"
"github.com/gofiber/fiber/v2"
)
type UsersController struct {
usersService *services.UsersService
}
func NewUsersController(usersService *services.UsersService) *UsersController {
return &UsersController{usersService: usersService}
}
func (uc *UsersController) Index(c *fiber.Ctx) error {
pageData, err := uc.queryPage(c)
if err != nil {
return err
}
viewData := map[string]any{
"Title": "Users",
"NavSection": "private",
"PageData": pageData,
}
for k, v := range localsTemplateData(c) {
viewData[k] = v
}
tmpl, err := template.ParseFiles(
"web/templates/layout.html",
"web/templates/public/_flash.html",
"web/templates/private/users/index.html",
"web/templates/private/users/_table.html",
)
if err != nil {
return err
}
return executeLayout(c, tmpl, viewData)
}
func (uc *UsersController) Table(c *fiber.Ctx) error {
pageData, err := uc.queryPage(c)
if err != nil {
return err
}
viewData := map[string]any{"PageData": pageData}
tmpl, err := template.ParseFiles("web/templates/private/users/_table.html")
if err != nil {
return err
}
c.Type("html", "utf-8")
return tmpl.ExecuteTemplate(c.Response().BodyWriter(), "users_table", viewData)
}
func (uc *UsersController) Modal(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return c.Status(fiber.StatusBadRequest).SendString("invalid user id")
}
user, err := uc.usersService.GetByID(uint(id))
if err != nil {
return err
}
if user == nil {
return c.Status(fiber.StatusNotFound).SendString("user not found")
}
viewData := map[string]any{"User": user}
tmpl, err := template.ParseFiles("web/templates/private/users/_modal.html")
if err != nil {
return err
}
c.Type("html", "utf-8")
return tmpl.ExecuteTemplate(c.Response().BodyWriter(), "users_modal", viewData)
}
func (uc *UsersController) queryPage(c *fiber.Ctx) (*services.UsersPage, error) {
page := parseIntOrDefault(c.Query("page"), 1)
pageSize := parseIntOrDefault(c.Query("pageSize"), 10)
q := strings.TrimSpace(c.Query("q"))
sort := c.Query("sort", "id")
dir := c.Query("dir", "asc")
pageData, err := uc.usersService.List(services.UsersQuery{
Q: q,
Sort: sort,
Dir: dir,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
return pageData, nil
}
func parseIntOrDefault(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func executeLayout(c *fiber.Ctx, tmpl *template.Template, viewData map[string]any) error {
httpmw.SetTemplateData(c, "NavSection", viewData["NavSection"])
c.Type("html", "utf-8")
return tmpl.ExecuteTemplate(c.Response().BodyWriter(), filepath.Base("web/templates/layout.html"), viewData)
}

View File

@ -18,30 +18,35 @@ func Seed(database *gorm.DB) error {
seedUsers := []models.User{ seedUsers := []models.User{
{ {
Name: "Admin User",
Email: "admin@example.com", Email: "admin@example.com",
Role: models.RoleAdmin, Role: models.RoleAdmin,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
}, },
{ {
Name: "Normal User",
Email: "user@example.com", Email: "user@example.com",
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
}, },
{ {
Name: "Demo One",
Email: "demo1@example.com", Email: "demo1@example.com",
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
}, },
{ {
Name: "Demo Two",
Email: "demo2@example.com", Email: "demo2@example.com",
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
PasswordHash: passwordHash, PasswordHash: passwordHash,
}, },
{ {
Name: "Demo Three",
Email: "demo3@example.com", Email: "demo3@example.com",
Role: models.RoleUser, Role: models.RoleUser,
EmailVerified: true, EmailVerified: true,
@ -62,6 +67,7 @@ func upsertUser(database *gorm.DB, user models.User) 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{
"name",
"role", "role",
"email_verified", "email_verified",
"password_hash", "password_hash",

View File

@ -22,6 +22,7 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg
return fmt.Errorf("init auth service: %w", err) return fmt.Errorf("init auth service: %w", err)
} }
authController := controllers.NewAuthController(authService) authController := controllers.NewAuthController(authService)
usersController := controllers.NewUsersController(services.NewUsersService(database))
app.Get("/healthz", func(c *fiber.Ctx) error { app.Get("/healthz", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
@ -39,10 +40,13 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg
app.Post("/forgot-password", authController.ForgotPassword) app.Post("/forgot-password", authController.ForgotPassword)
app.Get("/reset-password", authController.ShowResetPassword) app.Get("/reset-password", authController.ShowResetPassword)
app.Post("/reset-password", authController.ResetPassword) app.Post("/reset-password", authController.ResetPassword)
app.Get("/users", httpmw.RequireAuth(), usersController.Index)
app.Get("/users/table", httpmw.RequireAuth(), usersController.Table)
app.Get("/users/:id/modal", httpmw.RequireAuth(), usersController.Modal)
private := app.Group("/private", httpmw.RequireAuth()) private := app.Group("/private", httpmw.RequireAuth())
private.Get("/", func(c *fiber.Ctx) error { private.Get("/", func(c *fiber.Ctx) error {
return c.SendString("private area") return c.Redirect("/users")
}) })
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin()) admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())

View File

@ -9,6 +9,7 @@ const (
type User struct { type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
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"`

View File

@ -2,6 +2,8 @@ package repo
import ( import (
"errors" "errors"
"fmt"
"strings"
"trustcontact/internal/models" "trustcontact/internal/models"
@ -12,6 +14,14 @@ type UserRepo struct {
db *gorm.DB db *gorm.DB
} }
type UserListParams struct {
Query string
Sort string
Dir string
Page int
PageSize int
}
func NewUserRepo(db *gorm.DB) *UserRepo { func NewUserRepo(db *gorm.DB) *UserRepo {
return &UserRepo{db: db} return &UserRepo{db: db}
} }
@ -55,3 +65,65 @@ func (r *UserRepo) UpdatePasswordHash(userID uint, passwordHash string) error {
Where("id = ?", userID). Where("id = ?", userID).
Update("password_hash", passwordHash).Error Update("password_hash", passwordHash).Error
} }
func (r *UserRepo) List(params UserListParams) ([]models.User, int64, error) {
query := r.db.Model(&models.User{})
search := strings.TrimSpace(params.Query)
if search != "" {
like := "%" + strings.ToLower(search) + "%"
query = query.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ?", like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
orderBy := sanitizeSort(params.Sort)
orderDir := sanitizeDir(params.Dir)
orderClause := fmt.Sprintf("%s %s", orderBy, orderDir)
page := params.Page
if page < 1 {
page = 1
}
pageSize := params.PageSize
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
var users []models.User
if err := query.Order(orderClause).Limit(pageSize).Offset(offset).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func sanitizeSort(sort string) string {
switch strings.ToLower(strings.TrimSpace(sort)) {
case "id":
return "id"
case "name":
return "name"
case "email":
return "email"
default:
return "id"
}
}
func sanitizeDir(dir string) string {
switch strings.ToLower(strings.TrimSpace(dir)) {
case "desc":
return "desc"
default:
return "asc"
}
}

View File

@ -0,0 +1,132 @@
package services
import (
"strings"
"trustcontact/internal/models"
"trustcontact/internal/repo"
"gorm.io/gorm"
)
type UsersService struct {
users *repo.UserRepo
}
type UsersQuery struct {
Q string
Sort string
Dir string
Page int
PageSize int
}
type UsersPage struct {
Users []models.User
Total int64
Page int
PageSize int
TotalPages int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
Sort string
Dir string
Q string
}
func NewUsersService(database *gorm.DB) *UsersService {
return &UsersService{users: repo.NewUserRepo(database)}
}
func (s *UsersService) List(query UsersQuery) (*UsersPage, error) {
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
}
sort := normalizeSort(query.Sort)
dir := normalizeDir(query.Dir)
users, total, err := s.users.List(repo.UserListParams{
Query: strings.TrimSpace(query.Q),
Sort: sort,
Dir: dir,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
totalPages := 0
if total > 0 {
totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
}
if totalPages > 0 && page > totalPages {
page = totalPages
users, total, err = s.users.List(repo.UserListParams{
Query: strings.TrimSpace(query.Q),
Sort: sort,
Dir: dir,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
}
hasPrev := page > 1
hasNext := totalPages > 0 && page < totalPages
return &UsersPage{
Users: users,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
HasPrev: hasPrev,
HasNext: hasNext,
PrevPage: max(1, page-1),
NextPage: page + 1,
Sort: sort,
Dir: dir,
Q: strings.TrimSpace(query.Q),
}, nil
}
func (s *UsersService) GetByID(id uint) (*models.User, error) {
return s.users.FindByID(id)
}
func normalizeSort(sort string) string {
switch strings.ToLower(strings.TrimSpace(sort)) {
case "id", "name", "email":
return strings.ToLower(strings.TrimSpace(sort))
default:
return "id"
}
}
func normalizeDir(dir string) string {
if strings.ToLower(strings.TrimSpace(dir)) == "desc" {
return "desc"
}
return "asc"
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@ -17,6 +17,7 @@
.muted { color: #6b7280; font-size: 0.95rem; } .muted { color: #6b7280; font-size: 0.95rem; }
.row { display: flex; gap: 10px; flex-wrap: wrap; } .row { display: flex; gap: 10px; flex-wrap: wrap; }
</style> </style>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head> </head>
<body> <body>
<nav> <nav>

View File

@ -0,0 +1,13 @@
{{define "users_modal"}}
<div style="padding:16px;">
<h3 style="margin-top:0;">Dettaglio utente #{{.User.ID}}</h3>
<p><strong>Name:</strong> {{if .User.Name}}{{.User.Name}}{{else}}-{{end}}</p>
<p><strong>Email:</strong> {{.User.Email}}</p>
<p><strong>Role:</strong> {{.User.Role}}</p>
<p><strong>Verified:</strong> {{if .User.EmailVerified}}yes{{else}}no{{end}}</p>
<p><strong>Created:</strong> {{.User.CreatedAt}}</p>
<div class="row">
<button type="button" onclick="document.getElementById('userModal').removeAttribute('open')">Chiudi</button>
</div>
</div>
{{end}}

View File

@ -0,0 +1,48 @@
{{define "users_table"}}
{{ $p := .PageData }}
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
<thead>
<tr>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">
<a href="#" hx-get="/users/table?q={{$p.Q}}&sort=id&dir={{if and (eq $p.Sort "id") (eq $p.Dir "asc")}}desc{{else}}asc{{end}}&page=1&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">ID</a>
</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">
<a href="#" hx-get="/users/table?q={{$p.Q}}&sort=name&dir={{if and (eq $p.Sort "name") (eq $p.Dir "asc")}}desc{{else}}asc{{end}}&page=1&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Name</a>
</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">
<a href="#" hx-get="/users/table?q={{$p.Q}}&sort=email&dir={{if and (eq $p.Sort "email") (eq $p.Dir "asc")}}desc{{else}}asc{{end}}&page=1&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Email</a>
</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">Role</th>
<th style="text-align:left;border-bottom:1px solid #e5e7eb;padding:8px;">Azioni</th>
</tr>
</thead>
<tbody>
{{range $u := $p.Users}}
<tr>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{$u.ID}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{if $u.Name}}{{$u.Name}}{{else}}-{{end}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{$u.Email}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">{{$u.Role}}</td>
<td style="border-bottom:1px solid #f1f5f9;padding:8px;">
<button
hx-get="/users/{{$u.ID}}/modal"
hx-target="#userModal"
hx-swap="innerHTML"
hx-on::after-request="document.getElementById('userModal').setAttribute('open','')"
>Apri</button>
</td>
</tr>
{{else}}
<tr><td colspan="5" style="padding:12px;">Nessun utente trovato.</td></tr>
{{end}}
</tbody>
</table>
<div class="row" style="margin-top:12px;align-items:center;justify-content:space-between;">
<div class="muted">Totale: {{$p.Total}} utenti. Pagina {{$p.Page}}{{if gt $p.TotalPages 0}} / {{$p.TotalPages}}{{end}}</div>
<div class="row">
<button {{if not $p.HasPrev}}disabled{{end}} hx-get="/users/table?q={{$p.Q}}&sort={{$p.Sort}}&dir={{$p.Dir}}&page={{$p.PrevPage}}&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Prev</button>
<button {{if not $p.HasNext}}disabled{{end}} hx-get="/users/table?q={{$p.Q}}&sort={{$p.Sort}}&dir={{$p.Dir}}&page={{$p.NextPage}}&pageSize={{$p.PageSize}}" hx-target="#usersTableContainer" hx-swap="innerHTML">Next</button>
</div>
</div>
{{end}}

View File

@ -0,0 +1,19 @@
{{define "content"}}
<h1>Users</h1>
<p class="muted">Ricerca, ordinamento e paging server-side via HTMX.</p>
<form id="usersFilters" class="row" hx-get="/users/table" hx-target="#usersTableContainer" hx-swap="innerHTML">
<input type="text" name="q" placeholder="Cerca nome o email" value="{{.PageData.Q}}">
<input type="number" name="pageSize" min="1" max="100" value="{{.PageData.PageSize}}" style="max-width:120px;">
<input type="hidden" name="sort" value="{{.PageData.Sort}}">
<input type="hidden" name="dir" value="{{.PageData.Dir}}">
<input type="hidden" name="page" value="1">
<button type="submit">Cerca</button>
</form>
<div id="usersTableContainer" hx-get="/users/table?q={{.PageData.Q}}&sort={{.PageData.Sort}}&dir={{.PageData.Dir}}&page={{.PageData.Page}}&pageSize={{.PageData.PageSize}}" hx-trigger="load" hx-swap="innerHTML">
{{template "users_table" .}}
</div>
<ui-modal id="userModal"></ui-modal>
{{end}}