prompt 7
This commit is contained in:
parent
036aadb09a
commit
c60ff109a4
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -18,30 +18,35 @@ func Seed(database *gorm.DB) error {
|
|||
|
||||
seedUsers := []models.User{
|
||||
{
|
||||
Name: "Admin User",
|
||||
Email: "admin@example.com",
|
||||
Role: models.RoleAdmin,
|
||||
EmailVerified: true,
|
||||
PasswordHash: passwordHash,
|
||||
},
|
||||
{
|
||||
Name: "Normal User",
|
||||
Email: "user@example.com",
|
||||
Role: models.RoleUser,
|
||||
EmailVerified: true,
|
||||
PasswordHash: passwordHash,
|
||||
},
|
||||
{
|
||||
Name: "Demo One",
|
||||
Email: "demo1@example.com",
|
||||
Role: models.RoleUser,
|
||||
EmailVerified: true,
|
||||
PasswordHash: passwordHash,
|
||||
},
|
||||
{
|
||||
Name: "Demo Two",
|
||||
Email: "demo2@example.com",
|
||||
Role: models.RoleUser,
|
||||
EmailVerified: true,
|
||||
PasswordHash: passwordHash,
|
||||
},
|
||||
{
|
||||
Name: "Demo Three",
|
||||
Email: "demo3@example.com",
|
||||
Role: models.RoleUser,
|
||||
EmailVerified: true,
|
||||
|
|
@ -62,6 +67,7 @@ func upsertUser(database *gorm.DB, user models.User) error {
|
|||
result := database.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "email"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"role",
|
||||
"email_verified",
|
||||
"password_hash",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg
|
|||
return fmt.Errorf("init auth service: %w", err)
|
||||
}
|
||||
authController := controllers.NewAuthController(authService)
|
||||
usersController := controllers.NewUsersController(services.NewUsersService(database))
|
||||
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
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.Get("/reset-password", authController.ShowResetPassword)
|
||||
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.Get("/", func(c *fiber.Ctx) error {
|
||||
return c.SendString("private area")
|
||||
return c.Redirect("/users")
|
||||
})
|
||||
|
||||
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const (
|
|||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:120;index"`
|
||||
Email string `gorm:"size:320;uniqueIndex;not null"`
|
||||
PasswordHash string `gorm:"size:255;not null"`
|
||||
EmailVerified bool `gorm:"not null;default:false"`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package repo
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"trustcontact/internal/models"
|
||||
|
||||
|
|
@ -12,6 +14,14 @@ type UserRepo struct {
|
|||
db *gorm.DB
|
||||
}
|
||||
|
||||
type UserListParams struct {
|
||||
Query string
|
||||
Sort string
|
||||
Dir string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func NewUserRepo(db *gorm.DB) *UserRepo {
|
||||
return &UserRepo{db: db}
|
||||
}
|
||||
|
|
@ -55,3 +65,65 @@ func (r *UserRepo) UpdatePasswordHash(userID uint, passwordHash string) error {
|
|||
Where("id = ?", userID).
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
.muted { color: #6b7280; font-size: 0.95rem; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
Loading…
Reference in New Issue