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,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{
{
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",

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)
}
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())

View File

@@ -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"`

View File

@@ -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"
}
}

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
}