Compare commits

..

3 Commits

Author SHA1 Message Date
fabio c8784320cf feat: add user management dialogs for blocking, editing, and password changes
- Created UserBlockDialog.vue for blocking/unblocking users with status notifications.
- Implemented UserEditorDialog.vue for creating and editing user details, including account, details, and preferences tabs.
- Added UserPasswordDialog.vue for changing user passwords with validation.
- Defined types for user forms and dialogs in types.ts.
- Introduced user-store.ts for managing user state with Pinia, including fetching user data and handling errors.
2026-05-08 21:45:08 +02:00
fabio 0a7cc993d4 sistemato omitempty json 2026-05-05 14:19:12 +02:00
fabio 3b5c39ffc0 sistemato ts generator 2026-05-04 16:10:49 +02:00
35 changed files with 2891 additions and 1722 deletions

Binary file not shown.

View File

@ -115,3 +115,65 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
return c.JSON(responses.Success(u))
}
func (ac *AdminController) UpdateUser(c fiber.Ctx) error {
var req users.UpdateUserRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
user, err := users.UpdateUser(req)
if err != nil {
return err
}
return c.JSON(responses.Success(user))
}
// UpdateUserDetails replaces user fields and synchronizes details/preferences.
func (ac *AdminController) UpdateUserDetails(c fiber.Ctx) error {
var req users.UpdateUserDetailsRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
err := users.UpdateUserDetails(req)
if err != nil {
return err
}
user, err := users.GetUserByID(req.UserID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
return c.JSON(responses.Success(user))
}
// UpdateUserPreferences replaces user fields and synchronizes details/preferences.
func (ac *AdminController) UpdateUserPreferences(c fiber.Ctx) error {
var req users.UpdateUserPreferencesRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
err := users.UpdateUserPreferences(req)
if err != nil {
return err
}
user, err := users.GetUserByID(req.UserID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
return c.JSON(responses.Success(user))
}

View File

@ -1,6 +1,8 @@
package admin
import (
"server/internal/auth"
"github.com/gofiber/fiber/v3"
)
@ -8,9 +10,27 @@ func RegisterAdminRoutes(app fiber.Router) {
adminController := NewAdminController()
// Typescript: TSEndpoint= path=/api/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=admin.ListUsersResponse
app.Post("/admin/users", adminController.ListUsers)
app.Post("/admin/users", func(c fiber.Ctx) error {
return auth.IsPermitted(c, auth.AdminPermission|auth.SuperAdminPermission)
}, adminController.ListUsers)
// Typescript: TSEndpoint= path=/api/admin/users/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=users.User
app.Put("/admin/users/block", adminController.BlockUser)
app.Put("/admin/users/block", func(c fiber.Ctx) error {
return auth.IsPermitted(c, auth.AdminPermission|auth.SuperAdminPermission)
}, adminController.BlockUser)
// Typescript: TSEndpoint= path=/api/admin/updateUser; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.User
app.Put("/admin/updateUser", func(c fiber.Ctx) error {
return auth.IsPermitted(c, auth.AdminPermission|auth.SuperAdminPermission)
}, adminController.UpdateUser)
// Typescript: TSEndpoint= path=/api/admin/updateuserdetails; name=adminUpdateUserDetails; method=PUT; request=users.UpdateUserDetailsRequest; response=users.User
app.Put("/admin/updateuserdetails", func(c fiber.Ctx) error {
return auth.IsPermitted(c, auth.AdminPermission|auth.SuperAdminPermission)
}, adminController.UpdateUserDetails)
// Typescript: TSEndpoint= path=/api/admin/updateuserpreferences; name=adminUpdateUserPreferences; method=PUT; request=users.UpdateUserPreferencesRequest; response=users.User
app.Put("/admin/updateuserpreferences", func(c fiber.Ctx) error {
return auth.IsPermitted(c, auth.AdminPermission|auth.SuperAdminPermission)
}, adminController.UpdateUserPreferences)
}

View File

@ -1,23 +1,14 @@
package auth
import (
"server/internal/responses"
"server/internal/tokens"
"github.com/gofiber/fiber/v3"
)
/* const (
"superadmin" = SuperAdminPermission
"admin" = AdminPermission
"manager" = ManagerPermission
"content_creator" = ContentCreatorPermission
"user" = UserPermission
"guest" = GuestPermission
) */
type Role struct {
Name Permission `json:"name"`
Permission int `json:"permission"`
Name string `json:"name"`
Permission uint `json:"permission"`
}
var Roles = []Role{
@ -29,56 +20,29 @@ var Roles = []Role{
{"guest", GuestPermission},
}
// Typescript: type
type Permission string
// RolesData represents permissions of a user.
type RolesData string
// Typescript: enum=EnumPermission
// Typescript: enum=UserRole
const (
RoleSuperAdmin Permission = "superadmin"
RoleAdmin Permission = "admin"
RoleManager Permission = "manager"
RoleContentCreator Permission = "content_creator"
RoleUser Permission = "user"
RoleGuest Permission = "guest"
SuperAdminRole RolesData = "superadmin"
AdminRole RolesData = "admin"
ManagerRole RolesData = "manager"
ContentCreatorRole RolesData = "content_creator"
UserRole RolesData = "user"
GuestRole RolesData = "guest"
)
type Permissions struct {
SuperAdmin Permission `json:"superadmin"`
Admin Permission `json:"admin"`
Manager Permission `json:"manager"`
ContentCreator Permission `json:"content_creator"`
User Permission `json:"user"`
Guest Permission `json:"guest"`
}
const (
SuperAdminPermission = 0b1111111111111111
AdminPermission = 0b0111111111111111
ManagerPermission = 0b0010111111111111
ContentCreatorPermission = 0b0001111111111111
UserPermission = 0b0000000000000011
GuestPermission = 0b0000000000000001
SuperAdminPermission uint = 0b1111111111111111
AdminPermission uint = 0b0111111111111111
ManagerPermission uint = 0b0010111111111111
ContentCreatorPermission uint = 0b0001111111111111
UserPermission uint = 0b0000000000000011
GuestPermission uint = 0b0000000000000001
)
type AllRoles struct {
Roles map[string]string `json:"roles"`
}
func GetRoles(c fiber.Ctx) error {
a := AllRoles{
Roles: make(map[string]string),
}
a.Roles["RoleSuperAdmin"] = "superadmin"
a.Roles["RoleAdmin"] = "admin"
a.Roles["RoleManager"] = "manager"
a.Roles["RoleContentCreator"] = "content_creator"
a.Roles["RoleUser"] = "user"
a.Roles["RoleGuest"] = "guest"
return c.JSON(responses.Success(a))
}
func PermissionToString(p int) Permission {
func PermissionToString(p uint) string {
for _, role := range Roles {
if role.Permission == p {
return role.Name
@ -87,7 +51,7 @@ func PermissionToString(p int) Permission {
return "unknown"
}
func RoleToPermission(s Permission) int {
func RoleToPermission(s string) uint {
for _, role := range Roles {
if role.Name == s {
return role.Permission
@ -95,3 +59,20 @@ func RoleToPermission(s Permission) int {
}
return 0
}
func IsPermitted(c fiber.Ctx, permission uint) error {
claims := c.Locals("authClaims")
if claims == nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"authenticated": false,
})
}
p := claims.(*tokens.Claims).Permission
if p&permission == 0 {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"authenticated": true,
"authorized": false,
})
}
return c.Next()
}

View File

@ -3,6 +3,5 @@ package auth
import "github.com/gofiber/fiber/v3"
func RegisterAuthRoutes(app fiber.Router) {
// Typescript: TSEndpoint= path=/api/roles; name=getRoles; method=GET; response=auth.AllRoles
app.Get("/roles", GetRoles)
}

View File

@ -28,7 +28,7 @@ func GetAuthClaims(dbConn *gorm.DB, tokenService *tokens.TockenService) fiber.Ha
func AuthMe(c fiber.Ctx) error {
claims := c.Locals("authClaims")
if claims == nil {
return c.Status(fiber.StatusOK).JSON(fiber.Map{
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"authenticated": false,
})
}

View File

@ -33,7 +33,7 @@ func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
creds := make([]Credential, 0, n)
for i := 0; i < n; i++ {
now := time.Now().UTC()
uuid := gofakeit.UUID()
//id := gofakeit.UUID()
email := gofakeit.Email()
pw, err := randomPassword()
@ -51,8 +51,7 @@ func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
Password: passwordHash,
Permission: auth.PermissionToString(auth.SuperAdminPermission),
Status: users.UserStatusActive,
Types: users.UserTypes{"internal"},
UUID: uuid,
Type: users.UserType("internal"),
Details: &users.UserDetails{
Title: gofakeit.JobTitle(),
FirstName: gofakeit.FirstName(),

View File

@ -6,7 +6,6 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
"server/internal/auth"
"server/internal/config"
"time"
@ -23,8 +22,9 @@ type TockenService struct {
}
type Claims struct {
Username string `json:"username"`
Permission int `json:"permission"`
ID string `json:"id"`
Impersonator string `json:"impersonator,omitempty"`
Permission uint `json:"permission"`
TokenType string `json:"type"`
jwt.RegisteredClaims
}
@ -84,13 +84,13 @@ func HashToken(token string) string {
return hashToken(token)
}
func (s *TockenService) GenerateTokenPair(user string, permission auth.Permission) (TokenPair, error) {
access, err := s.GenerateToken(user, permission, TokenTypeAccess, s.accessExpiry)
func (s *TockenService) GenerateTokenPair(id string, permission uint) (TokenPair, error) {
access, err := s.GenerateToken(id, permission, TokenTypeAccess, s.accessExpiry)
if err != nil {
return TokenPair{}, err
}
refresh, err := s.GenerateToken(user, permission, TokenTypeRefresh, s.refreshExpiry)
refresh, err := s.GenerateToken(id, permission, TokenTypeRefresh, s.refreshExpiry)
if err != nil {
return TokenPair{}, err
}
@ -117,7 +117,7 @@ func (s *TockenService) Refresh(refreshToken string) (TokenPair, error) {
if claims.TokenType != TokenTypeRefresh {
return TokenPair{}, errors.New("refresh token required")
}
return s.GenerateTokenPair(claims.Username, auth.PermissionToString(claims.Permission))
return s.GenerateTokenPair(claims.ID, claims.Permission)
}
func (s *TockenService) ValidateToken(tokenString string) (*Claims, error) {
@ -158,12 +158,12 @@ func (s *TockenService) ParseToken(tokenString string) (*Claims, error) {
return claims, nil
}
func (s *TockenService) GenerateToken(user string, permission auth.Permission, tokenType string, expiry time.Duration) (string, error) {
p := auth.RoleToPermission(permission)
func (s *TockenService) GenerateToken(id string, permission uint, tokenType string, expiry time.Duration) (string, error) {
claims := Claims{
Username: user,
Permission: p,
ID: id,
Impersonator: "",
Permission: permission,
TokenType: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: s.cfg.Issuer,

View File

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
@ -33,13 +32,13 @@ func NewUserController(tockenService *tokens.TockenService) *UserController {
}
}
// GetUser returns a single user by UUID.
// GetUser returns a single user by ID.
func (uc *UserController) GetUser(c fiber.Ctx) error {
user, err := loadUserByUUID(c, c.Params("uuid"))
user, err := GetUserByID(c.Params("id"))
if err != nil {
return err
}
return c.JSON(responses.Success(ToUserProfile(user)))
return c.JSON(responses.Success(user))
}
// CreateUser creates a user together with optional details and preferences.
@ -52,58 +51,12 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return err
}
db, err := db.DBFromCtx(c)
user, err := CreateUser(req)
if err != nil {
return err
}
var existing User
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
}
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
user := User{
Email: req.Email,
Name: req.Name,
Password: hashedPassword,
Permission: req.Permission,
Status: func() UserStatus {
if req.Status == "" {
return UserStatusPending
}
return req.Status
}(),
Types: func() UserTypes {
if len(req.Types) == 0 {
return UserTypes{"internal"}
}
return req.Types
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
Details: req.Details,
Preferences: req.Preferences,
CreatedAt: &now,
UpdatedAt: &now,
}
if err := db.Create(&user).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
}
if err := db.Preload("Details").Preload("Preferences").First(&user, user.ID).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
}
return c.Status(fiber.StatusCreated).JSON(responses.Success(ToUserProfile(&user)))
return c.Status(fiber.StatusCreated).JSON(responses.Success(user))
}
// UpdateUser replaces user fields and synchronizes details/preferences.
@ -116,84 +69,42 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
return err
}
db, err := db.DBFromCtx(c)
user, err := UpdateUser(req)
if err != nil {
return err
}
user, err := loadUserByUUID(c, req.UUID)
return c.JSON(responses.Success(user))
}
// UpdateUserDetails replaces user fields and synchronizes details/preferences.
func (uc *UserController) UpdateUserDetails(c fiber.Ctx) error {
var req UpdateUserDetailsRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
err := UpdateUserDetails(req)
if err != nil {
return err
}
if req.Email != user.Email {
var existing User
if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID {
return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
}
user, err := GetUserByID(req.UserID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
now := time.Now().UTC()
user.Name = req.Name
user.Email = req.Email
user.Avatar = req.Avatar
user.UpdatedAt = &now
user.Permission = req.Permission
if req.Status != "" {
user.Status = req.Status
}
if len(req.Types) > 0 {
user.Types = req.Types
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(user).Error; err != nil {
return err
}
if err := syncUserDetails(tx, user.ID, req.Details); err != nil {
return err
}
if err := syncUserPreferences(tx, user.ID, req.Preferences); err != nil {
return err
}
return nil
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user")
}
if err := db.Preload("Details").Preload("Preferences").First(user, user.ID).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
}
return c.JSON(responses.Success(ToUserProfile(user)))
return c.JSON(responses.Success(user))
}
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
db, err := db.DBFromCtx(c)
if err != nil {
if err := DeleteUser(c.Params("uuid")); err != nil {
return err
}
user, err := loadUserByID(c)
if err != nil {
return err
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", user.ID).Delete(&UserDetails{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", user.ID).Delete(&UserPreferences{}).Error; err != nil {
return err
}
return tx.Delete(user).Error
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
}
@ -232,7 +143,7 @@ func (uc *UserController) Login(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "invalid user role")
}
token, err := uc.TockenService.GenerateTokenPair(user.Email, auth.PermissionToString(permission))
token, err := uc.TockenService.GenerateTokenPair(user.ID, permission)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
@ -300,14 +211,14 @@ func (uc *UserController) Register(c fiber.Ctx) error {
}
return req.Status
}(),
Types: func() UserTypes {
if len(req.Types) == 0 {
return UserTypes{"internal"}
Type: func() UserType {
if len(req.Type) == 0 {
return UserType("internal")
}
return req.Types
return req.Type
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
ID: uuid.NewString(),
Details: req.Details,
Preferences: func() *UserPreferences {
if req.Preferences == nil {
@ -527,107 +438,6 @@ func (uc *UserController) ValidToken(c fiber.Ctx) error {
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
}
func loadUserByID(c fiber.Ctx) (*User, error) {
id, err := strconv.Atoi(c.Params("id"))
if err != nil || id <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
db, err := db.DBFromCtx(c)
if err != nil {
return nil, err
}
var user User
if err := db.Preload("Details").Preload("Preferences").First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return &user, nil
}
func loadUserByUUID(c fiber.Ctx, uuid string) (*User, error) {
if uuid == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
}
db, err := db.DBFromCtx(c)
if err != nil {
return nil, err
}
var user User
if err := db.Preload("Details").Preload("Preferences").First(&user, "uuid = ?", uuid).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return &user, nil
}
func syncUserDetails(tx *gorm.DB, userID int, input *UserDetails) error {
if input == nil {
return tx.Where("user_id = ?", userID).Delete(&UserDetails{}).Error
}
var details UserDetails
if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
details = UserDetails{UserID: userID}
} else {
return err
}
}
details.Title = input.Title
details.FirstName = input.FirstName
details.LastName = input.LastName
details.Address = input.Address
details.City = input.City
details.ZipCode = input.ZipCode
details.Country = input.Country
details.Phone = input.Phone
if details.ID == 0 {
return tx.Create(&details).Error
}
return tx.Save(&details).Error
}
func syncUserPreferences(tx *gorm.DB, userID int, input *UserPreferences) error {
if input == nil {
return tx.Where("user_id = ?", userID).Delete(&UserPreferences{}).Error
}
var preferences UserPreferences
if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
preferences = UserPreferences{UserID: userID}
} else {
return err
}
}
preferences.UseIdle = input.UseIdle
preferences.IdleTimeout = input.IdleTimeout
preferences.UseIdlePassword = input.UseIdlePassword
preferences.IdlePin = input.IdlePin
preferences.UseDirectLogin = input.UseDirectLogin
preferences.UseQuadcodeLogin = input.UseQuadcodeLogin
preferences.SendNoticesMail = input.SendNoticesMail
preferences.Language = input.Language
if preferences.ID == 0 {
return tx.Create(&preferences).Error
}
return tx.Save(&preferences).Error
}
// Me returns the authenticated user's profile (short format).
func (uc *UserController) Me(c fiber.Ctx) error {
@ -638,25 +448,25 @@ func (uc *UserController) Me(c fiber.Ctx) error {
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return c.JSON(responses.Success("missing token header"))
return fiber.NewError(fiber.StatusForbidden, "missing token header")
}
claims, err := tokenService.ValidateAccessToken(tokenString)
if err != nil {
return c.JSON(responses.Success("bad token"))
return fiber.NewError(fiber.StatusUnauthorized, "bad token")
}
db, err := db.DBFromCtx(c)
if err != nil {
return c.JSON(responses.Success("failed to load db"))
return fiber.NewError(fiber.StatusInternalServerError, "failed to load db")
}
var user User
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if err := db.Preload("Details").Preload("Preferences").Where("id = ?", claims.ID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(responses.Success("user not found"))
return fiber.NewError(fiber.StatusNotFound, "user not found")
}
return c.JSON(responses.Success("failed to load user"))
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return c.JSON(responses.Success(&user))
@ -678,7 +488,7 @@ func (us *UserController) Refresh(c fiber.Ctx) error {
if claims.TokenType != tokens.TokenTypeRefresh {
return fiber.NewError(fiber.StatusUnauthorized, "refresh token required")
}
tokens, err := us.TockenService.GenerateTokenPair(claims.Username, auth.PermissionToString(claims.Permission))
tokens, err := us.TockenService.GenerateTokenPair(claims.ID, claims.Permission)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
@ -688,21 +498,18 @@ func (us *UserController) Refresh(c fiber.Ctx) error {
// update user password by Claims
func (us *UserController) UpdatePassword(c fiber.Ctx) error {
var req UpdatePasswordRequest
claims := c.Locals("authClaims")
if claims == nil {
return fiber.NewError(fiber.StatusForbidden, "forbidden")
}
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
err = UpdateUserPassword(db, req, c.Params("uuid"))
if err != nil {
if err := UpdateUserPassword(req, claims.(tokens.Claims).ID); err != nil {
return err
}

View File

@ -1,77 +1,75 @@
package users
import (
"server/internal/auth"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// BeforeCreate will set a UUID rather than numeric ID.
func (base *User) BeforeCreate(tx *gorm.DB) error {
if base.ID == "" {
id, err := uuid.NewRandom()
if err != nil {
return err
}
base.ID = id.String()
}
return nil
}
type User struct {
ID int `json:"id" gorm:"primaryKey"`
ID string `json:"id" gorm:"type:uuid;primary_key;"`
Email string `json:"email" gorm:"uniqueIndex;size:255"`
Name string `json:"name" gorm:"size:255"`
Password string `json:"-" gorm:"size:255"`
Permission auth.Permission `json:"permission"`
Types UserTypes `json:"types" gorm:"type:text;serializer:json"`
Permission string `json:"permission"`
Type UserType `json:"type"`
Status UserStatus `json:"status" gorm:"type:text;default:'pending'"`
ActivatedAt *time.Time `json:"activatedAt" ts:"type=Nullable<Date>"`
UUID string `json:"uuid" gorm:"size:36"`
Details *UserDetails `json:"details" gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
Preferences *UserPreferences `json:"preferences" gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
Avatar *string `json:"avatar" gorm:"size:512"`
CreatedAt *time.Time `json:"createdAt" ts:"type=Nullable<Date>"`
UpdatedAt *time.Time `json:"updatedAt" ts:"type=Nullable<Date>"`
DeletedAt *gorm.DeletedAt `json:"-" gorm:"index" ts:"type=Nullable<Date>"`
ActivatedAt *time.Time `json:"activatedAt" ts:"type=Nullable<Date>"`
CreatedAt *time.Time `json:"createdAt,omitempty" ts:"type=Date"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
DeletedAt *gorm.DeletedAt `json:"-" gorm:"index" `
}
// UserTypes is stored as JSON array (e.g. ["internal","external"]).
type UserTypes []string
// UserProfile is the safe full representation of a user returned by CRUD endpoints.
type UserProfile struct {
ID int `json:"id"`
type UpdateUserProfileRequest struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Permission auth.Permission `json:"permission"`
Types UserTypes `json:"types"`
Permission string `json:"permission"`
Type UserType `json:"type"`
Status UserStatus `json:"status"`
ActivatedAt *time.Time `json:"activatedAt" ts:"type=Nullable<Date>"`
UUID string `json:"uuid"`
Details *UserDetails `json:"details"`
Preferences *UserPreferences `json:"preferences"`
Avatar *string `json:"avatar"`
CreatedAt *time.Time `json:"createdAt" ts:"type=Nullable<Date>"`
UpdatedAt *time.Time `json:"updatedAt" ts:"type=Nullable<Date>"`
}
// ToUserProfile maps a User to a full response without exposing the password hash.
func ToUserProfile(u *User) UserProfile {
if u == nil {
return UserProfile{}
}
return UserProfile{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Permission: u.Permission,
Types: u.Types,
Status: u.Status,
ActivatedAt: u.ActivatedAt,
UUID: u.UUID,
Details: u.Details,
Preferences: u.Preferences,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
type UpdateUserAvatarRequest struct {
ID string `json:"id" validate:"required,uuid4"`
Img []byte `json:"img"`
}
// UserPreferences holds per-user settings stored as JSON.
type UserPreferences struct {
ID int `json:"id" gorm:"primaryKey"`
UserID int `json:"userId" gorm:"index"`
UserID string `json:"userId" gorm:"type:uuid;column:user_foreign_key;not null;"`
UseIdle bool `json:"useIdle"`
IdleTimeout int `json:"idleTimeout"`
UseIdlePassword bool `json:"useIdlePassword"`
IdlePin string `json:"idlePin"`
UseDirectLogin bool `json:"useDirectLogin"`
UseQuadcodeLogin bool `json:"useQuadcodeLogin"`
SendNoticesMail bool `json:"sendNoticesMail"`
Language string `json:"language"`
CreatedAt *time.Time `json:"createdAt,omitempty" ts:"type=Date"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
}
type UpdateUserPreferencesRequest struct {
ID int `json:"id"`
UserID string `json:"userId"`
UseIdle bool `json:"useIdle"`
IdleTimeout int `json:"idleTimeout"`
UseIdlePassword bool `json:"useIdlePassword"`
@ -80,15 +78,28 @@ type UserPreferences struct {
UseQuadcodeLogin bool `json:"useQuadcodeLogin"`
SendNoticesMail bool `json:"sendNoticesMail"`
Language string `json:"language"`
CreatedAt *time.Time `json:"createdAt" ts:"type=Nullable<Date>"`
UpdatedAt *time.Time `json:"updatedAt" ts:"type=Nullable<Date>"`
}
// UserDetails holds optional profile data.
type UserDetails struct {
ID int `json:"id" gorm:"primaryKey"`
UserID int `json:"userId" gorm:"index"`
UserID string `json:"userId" gorm:"type:uuid;column:user_foreign_key;not null;"`
Title string `json:"title"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Address string `json:"address"`
City string `json:"city"`
ZipCode string `json:"zipCode"`
Country string `json:"country"`
Phone string `json:"phone"`
CreatedAt *time.Time `json:"createdAt,omitempty" ts:"type=Date"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
}
type UpdateUserDetailsRequest struct {
ID int `json:"id"`
UserID string `json:"userId" gorm:"index"`
Title string `json:"title"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
@ -97,8 +108,6 @@ type UserDetails struct {
ZipCode string `json:"zipCode"`
Country string `json:"country"`
Phone string `json:"phone"`
CreatedAt *time.Time `json:"createdAt" ts:"type=Nullable<Date>"`
UpdatedAt *time.Time `json:"updatedAt" ts:"type=Nullable<Date>"`
}
// UserDetails holds optional profile data.
@ -106,27 +115,27 @@ type UserDetails struct {
// Session tracks logins with browser metadata.
type Session struct {
ID int `json:"id" gorm:"primaryKey"`
UserID *int `json:"userId" gorm:"index"`
ID int `gorm:"primaryKey"`
UserID *string `json:"userId" gorm:"index"`
Username string `json:"username" gorm:"size:255"`
AccessTokenHash string `json:"-" gorm:"size:128;index"`
RefreshTokenHash string `json:"-" gorm:"size:128;index"`
ExpiresAt time.Time `json:"expiresAt" ts:"type=Nullable<Date>" gorm:"index"`
IPAddress string `json:"ipAddress" gorm:"size:64"`
UserAgent string `json:"userAgent" gorm:"size:512"`
CreatedAt time.Time `json:"createdAt" ts:"type=Nullable<Date>"`
UpdatedAt *time.Time `json:"updatedAt" ts:"type=Nullable<Date>"`
CreatedAt time.Time `json:"createdAt,omitempty" ts:"type=Date"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
DeletedAt *gorm.DeletedAt `json:"-" gorm:"index" `
}
type PasswordResetToken struct {
ID int `json:"id" gorm:"primaryKey"`
UserID int `json:"userId" gorm:"index"`
ID int `gorm:"primaryKey"`
UserID string `json:"userId" gorm:"index"`
TokenHash string `json:"-" gorm:"size:64;uniqueIndex"`
ExpiresAt time.Time `json:"expiresAt" ts:"type=Nullable<Date>" gorm:"index"`
UsedAt *time.Time `json:"usedAt" ts:"type=Nullable<Date>"`
CreatedAt *time.Time `json:"createdAt" ts:"type=Nullable<Date>"`
UpdatedAt *time.Time `json:"updatedAt" ts:"type=Nullable<Date>"`
ExpiresAt time.Time `json:"expiresAt,omitempty" ts:"type=Date" gorm:"index"`
UsedAt *time.Time `json:"usedAt,omitempty" ts:"type=Date"`
CreatedAt *time.Time `json:"createdAt" ts:"type=Date"`
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
DeletedAt *gorm.DeletedAt `json:"-" gorm:"index"`
}
@ -140,6 +149,16 @@ const (
UserStatusDisabled UserStatus = "disabled"
)
// UserTypes represents different types of a user.
type UserType string
// Typescript: enum=UserTypes
const (
UserTypeInternal UserType = "internal"
UserTypeExternal UserType = "external"
UserTypeSurveyor UserType = "surveyor"
)
type LoginRequest struct {
Username string `json:"username" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
@ -159,20 +178,18 @@ type ResetPasswordRequest struct {
}
type UpdatePasswordRequest struct {
ID string `json:"id" validate:"required,uuid4"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
type UpdateUserRequest struct {
UUID string `json:"uuid" validate:"required,uuid4"`
ID string `json:"id" validate:"required,uuid4"`
Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"omitempty,min=8,max=128"`
Permission auth.Permission `json:"permission"`
Permission string `json:"permission"`
Status UserStatus `json:"status"`
Types UserTypes `json:"types"`
Avatar *string `json:"avatar"`
Details *UserDetails `json:"details"`
Preferences *UserPreferences `json:"preferences"`
Type UserType `json:"type"`
}
// UserCreateRequest captures the minimal payload to create a user.
@ -181,10 +198,10 @@ type UserCreateRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
Permission auth.Permission `json:"permission"`
Permission string `json:"permission"`
Status UserStatus `json:"status"`
Types UserTypes `json:"types"`
Avatar *string `json:"avatar"`
Details *UserDetails `json:"details" `
Preferences *UserPreferences `json:"preferences" `
Type UserType `json:"type"`
Avatar *string `json:"avatar,omitempty"`
Details *UserDetails `json:"details,omitempty"`
Preferences *UserPreferences `json:"preferences,omitempty"`
}

View File

@ -0,0 +1,338 @@
package users
import (
"encoding/base64"
"errors"
"server/internal/db"
"server/internal/systemUtils"
"time"
"github.com/gofiber/fiber/v3"
"github.com/google/uuid"
"gorm.io/gorm"
)
func repositoryDB() (*gorm.DB, error) {
database, err := db.GetDB()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "database unavailable")
}
return database, nil
}
func CreateUser(createUserRequest UserCreateRequest) (*User, error) {
database, err := repositoryDB()
if err != nil {
return nil, err
}
var existing User
if err := database.Where("email = ?", createUserRequest.Email).First(&existing).Error; err == nil {
return nil, fiber.NewError(fiber.StatusConflict, "user already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
}
hashedPassword, err := systemUtils.HashPassword(createUserRequest.Password)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
user := User{
Email: createUserRequest.Email,
Name: createUserRequest.Name,
Password: hashedPassword,
Permission: createUserRequest.Permission,
Status: func() UserStatus {
if createUserRequest.Status == "" {
return UserStatusPending
}
return createUserRequest.Status
}(),
Type: func() UserType {
if createUserRequest.Type == "" {
return UserType("internal")
}
return createUserRequest.Type
}(),
Avatar: func() *string {
if createUserRequest.Avatar == nil {
return nil
}
return createUserRequest.Avatar
}(),
ID: uuid.NewString(),
Details: func() *UserDetails {
if createUserRequest.Details == nil {
return nil
}
return createUserRequest.Details
}(),
Preferences: func() *UserPreferences {
if createUserRequest.Preferences == nil {
return nil
}
return createUserRequest.Preferences
}(),
CreatedAt: &now,
UpdatedAt: &now,
}
if err := database.Create(&user).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
}
return GetUserByID(user.ID)
}
func UpdateUser(updateUserRequest UpdateUserRequest) (*User, error) {
database, err := repositoryDB()
if err != nil {
return nil, err
}
user, err := GetUserByID(updateUserRequest.ID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
user.Name = updateUserRequest.Name
user.Email = updateUserRequest.Email
user.UpdatedAt = &now
user.Permission = updateUserRequest.Permission
if updateUserRequest.Status != "" {
user.Status = updateUserRequest.Status
}
if updateUserRequest.Type != "" {
user.Type = updateUserRequest.Type
}
user.Permission = updateUserRequest.Permission
if err := database.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(user).Error; err != nil {
return err
}
return nil
}); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to update user")
}
return GetUserByID(user.ID)
}
func UpdateUserDetails(updateUserDetails UpdateUserDetailsRequest) error {
database, err := repositoryDB()
if err != nil {
return err
}
if updateUserDetails.UserID == "" {
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
input := &UserDetails{
UserID: updateUserDetails.UserID,
Title: updateUserDetails.Title,
FirstName: updateUserDetails.FirstName,
LastName: updateUserDetails.LastName,
Address: updateUserDetails.Address,
City: updateUserDetails.City,
ZipCode: updateUserDetails.ZipCode,
Country: updateUserDetails.Country,
Phone: updateUserDetails.Phone,
}
if err := database.Transaction(func(tx *gorm.DB) error {
return syncUserDetails(tx, updateUserDetails.UserID, input)
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user details")
}
return nil
}
func UpdateUserPreferences(updateUserPreferences UpdateUserPreferencesRequest) error {
database, err := repositoryDB()
if err != nil {
return err
}
if updateUserPreferences.UserID == "" {
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
input := &UserPreferences{
UserID: updateUserPreferences.UserID,
UseIdle: updateUserPreferences.UseIdle,
IdleTimeout: updateUserPreferences.IdleTimeout,
UseIdlePassword: updateUserPreferences.UseIdlePassword,
IdlePin: updateUserPreferences.IdlePin,
UseDirectLogin: updateUserPreferences.UseDirectLogin,
UseQuadcodeLogin: updateUserPreferences.UseQuadcodeLogin,
SendNoticesMail: updateUserPreferences.SendNoticesMail,
Language: updateUserPreferences.Language,
}
if err := database.Transaction(func(tx *gorm.DB) error {
return syncUserPreferences(tx, updateUserPreferences.UserID, input)
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user preferences")
}
return nil
}
func UpdateUserAvatar(updateUserAvatarRequest UpdateUserAvatarRequest) (*User, error) {
database, err := repositoryDB()
if err != nil {
return nil, err
}
user, err := GetUserByID(updateUserAvatarRequest.ID)
if err != nil {
return nil, err
}
avatar := base64.StdEncoding.EncodeToString(updateUserAvatarRequest.Img)
now := time.Now().UTC()
if err := database.Model(user).Updates(map[string]any{
"avatar": avatar,
"updated_at": now,
}).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to update user avatar")
}
return GetUserByID(user.ID)
}
func UpdateUserPassword(req UpdatePasswordRequest, id string) error {
database, err := repositoryDB()
if err != nil {
return err
}
user := User{}
if err := database.Where("uuid = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
if err := database.Model(&user).Updates(map[string]any{
"password": hashedPassword,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update password")
}
if err := database.Where("user_id = ?", user.ID).Delete(&Session{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to revoke sessions")
}
return nil
}
func GetUserByID(id string) (*User, error) {
if id == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
database, err := repositoryDB()
if err != nil {
return nil, err
}
var user User
if err := database.Preload("Details").Preload("Preferences").First(&user, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "user not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return &user, nil
}
func DeleteUser(uuid string) error {
database, err := repositoryDB()
if err != nil {
return err
}
user, err := GetUserByID(uuid)
if err != nil {
return err
}
if err := database.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", user.ID).Delete(&UserDetails{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", user.ID).Delete(&UserPreferences{}).Error; err != nil {
return err
}
return tx.Delete(user).Error
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
}
return nil
}
func syncUserDetails(tx *gorm.DB, userID string, input *UserDetails) error {
if input == nil {
return tx.Where("user_id = ?", userID).Delete(&UserDetails{}).Error
}
var details UserDetails
if err := tx.Where("user_id = ?", userID).First(&details).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
details = UserDetails{UserID: userID}
} else {
return err
}
}
details.Title = input.Title
details.FirstName = input.FirstName
details.LastName = input.LastName
details.Address = input.Address
details.City = input.City
details.ZipCode = input.ZipCode
details.Country = input.Country
details.Phone = input.Phone
if details.ID == 0 {
return tx.Create(&details).Error
}
return tx.Save(&details).Error
}
func syncUserPreferences(tx *gorm.DB, userID string, input *UserPreferences) error {
if input == nil {
return tx.Where("user_id = ?", userID).Delete(&UserPreferences{}).Error
}
var preferences UserPreferences
if err := tx.Where("user_id = ?", userID).First(&preferences).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
preferences = UserPreferences{UserID: userID}
} else {
return err
}
}
preferences.UseIdle = input.UseIdle
preferences.IdleTimeout = input.IdleTimeout
preferences.UseIdlePassword = input.UseIdlePassword
preferences.IdlePin = input.IdlePin
preferences.UseDirectLogin = input.UseDirectLogin
preferences.UseQuadcodeLogin = input.UseQuadcodeLogin
preferences.SendNoticesMail = input.SendNoticesMail
preferences.Language = input.Language
if preferences.ID == 0 {
return tx.Create(&preferences).Error
}
return tx.Save(&preferences).Error
}

View File

@ -0,0 +1,8 @@
CreateUser(createUserRequest CreateUserRequest)
UpdateUser(updateUserRequest UpdateUserRequest)
UpadteUserDetails(upadteUserDetails UpadteUserDetailsRequest)
UpdateUserPreferences(updateUserPreferences UpdateUserPreferencesRequest)
UpdateUserAvatar(updateUserAvatarRequest UpdateUserAvatarRequest)
UpdatePassword(updatePasswordRequest UpdatePasswordRequest)
GetUserByUUID(uuid string)
DeleteUser(uuid string)

View File

@ -1,35 +0,0 @@
package users
import (
"errors"
"server/internal/systemUtils"
"time"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
func UpdateUserPassword(db *gorm.DB, req UpdatePasswordRequest, uuid string) error {
user := User{}
if err := db.Where("uuid = ?", uuid).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
if err := db.Model(&user).Updates(map[string]any{
"password": hashedPassword,
"updated_at": now,
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update password")
}
if err := db.Where("user_id = ?", user.ID).Delete(&Session{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to revoke sessions")
}
return nil
}

View File

@ -24,8 +24,8 @@ func RegisterUserRoutes(app fiber.Router) {
userController := NewUserController(tockenService)
// Typescript: TSEndpoint= path=/api/users/:uuid; name=getUser; method=GET; response=users.User
app.Get("/users/:uuid", userController.GetUser)
// Typescript: TSEndpoint= path=/api/users/:id; name=getUser; method=GET; response=users.User
app.Get("/users/:id", userController.GetUser)
// Typescript: TSEndpoint= path=/api/users; name=createUser; method=POST; request=users.UserCreateRequest; response=users.User
app.Post("/users", userController.CreateUser)
@ -33,8 +33,11 @@ func RegisterUserRoutes(app fiber.Router) {
// Typescript: TSEndpoint= path=/api/users/update; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.User
app.Put("/users/update", userController.UpdateUser)
// Typescript: TSEndpoint= path=/api/users/:uuid; name=deleteUser; method=DELETE; response=responses.SimpleResponse
app.Delete("/users/:uuid", userController.DeleteUser)
// Typescript: TSEndpoint= path=/api/users/update/details; name=updateUserDetails; method=PUT; request=users.UpdateUserDetailsRequest; response=users.User
app.Put("/users/update/details", userController.UpdateUserDetails)
// Typescript: TSEndpoint= path=/api/users/:id; name=deleteUser; method=DELETE; response=responses.SimpleResponse
app.Delete("/users/:id", userController.DeleteUser)
// Typescript: TSEndpoint= path=/api/auth/me; name=me; method=GET; response=users.User
app.Get("/auth/me", middleware.AuthMe, userController.Me)

View File

@ -11,6 +11,7 @@ import (
"io"
"log"
"os"
"slices"
"strings"
"golang.org/x/tools/go/packages"
@ -79,6 +80,285 @@ type TSSourceFile struct {
Len int
}
func (i *TSInfo) Populate(path string, excludedPackages ExcudedPackages) {
i.Packages = make(map[string]TSInfoPakage)
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedSyntax,
Dir: path,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
log.Fatal(err)
}
if packages.PrintErrors(pkgs) > 0 {
log.Fatal("package loading failed")
}
//to get line info for endpoints and typescript declarations
for _, loadedPkg := range pkgs {
pkg := loadedPkg.Name
// skip packages
skip := false
for _, excluded := range excludedPackages {
if pkg == excluded {
skip = true
break
}
}
if skip {
continue
}
// initialize package info if not exists
if _, ok := i.Packages[pkg]; !ok {
ip := TSInfoPakage{}
ip.MakeMaps()
i.Packages[pkg] = ip
}
// start parsing files
for _, n := range loadedPkg.CompiledGoFiles {
// search for declarative // Typescript: declarations and endpoints
i.parseTypescriptDeclarations(pkg, n)
}
// parse doc to get all types and consts
docPkg, err := doc.NewFromFiles(loadedPkg.Fset, loadedPkg.Syntax, loadedPkg.PkgPath)
if err != nil {
exitOnError(err)
}
for _, t := range docPkg.Types {
i.getType(pkg, t)
}
for _, c := range docPkg.Consts {
i.getConst(pkg, c)
}
}
}
// estrae le definizioni typescript // Typescript: ...
func (ts *TSInfo) parseTypescriptDeclarations(p string, n string) {
pkg := ts.Packages[p]
f, err := os.OpenFile(n, os.O_RDONLY, os.ModePerm)
if err != nil {
log.Fatalf("open file error: %v", err)
return
}
defer f.Close()
l := 1
dat := ""
lines := []TSSourceLine{}
rd := bufio.NewReader(f)
// scansione del file per trovare le dichiarazioni typescript
for {
line, err := rd.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
log.Fatalf("read file line error: %v", err)
return
}
lines = append(lines, TSSourceLine{Pos: l, End: l + len(line), Line: l, Source: line})
l++
dat += line
// è una definizione typescript?
if strings.Contains(line, "// Typescript:") {
// typescript type, ad esempio: // Typescript: TStype=MyType=string | number
if strings.Contains(line, "TStype=") {
p := strings.Index(line, "TStype=")
s := strings.Trim(line[p+len("TStype="):], " ")
a := strings.Split(s, "=")
if len(a) == 2 {
t := TSType{
Name: strings.Trim(a[0], " "),
Typescript: true,
Type: "UserDefined",
TsType: strings.Trim(a[1], " "),
dependOn: false,
}
pkg.types[strings.Trim(a[0], " ")] = t
}
}
// dicchiarazione typescript, ad esempio: // Typescript: TSDeclaration=MyType=string | number
if strings.Contains(line, "TSDeclaration=") {
p := strings.Index(line, "TSDeclaration=")
s := strings.Trim(line[p+len("TSDeclaration="):], " ")
a := strings.Split(s, "=")
if len(a) == 2 {
t := TSDec{
Name: strings.Trim(a[0], " "),
Value: strings.Trim(a[1], " "),
}
pkg.decs[strings.Trim(a[0], " ")] = t
}
}
// dichiarazione endpoint, ad esempio: // Typescript: TSEndpoint= path=/api/auth/login; name=login; method=POST; request=users.LoginRequest; response=tokens.TokenPair
if strings.Contains(line, "TSEndpoint= ") {
e := ParseEndpoint(line, n, l)
if _, ok := pkg.endpoints[e.Name]; ok {
exitOnError(fmt.Errorf("enpoint name %s allready in use: %s", e.Name, line))
}
pkg_info := pkg
pkg_info.endpoints[e.Name] = e
pkg_info.imports = e.Imports
pkg_info.isUsed = true
pkg = pkg_info
}
}
}
}
func (i *TSInfo) getType(p string, t *doc.Type) {
var isTypescript = strings.HasPrefix(t.Doc, "Typescript:")
if isTypescript {
command := strings.TrimPrefix(t.Doc, "Typescript:")
command = strings.TrimSpace(command)
command = strings.Trim(command, "\n")
}
for _, spec := range t.Decl.Specs {
if len(t.Consts) > 0 {
i.getConst(p, t.Consts[0])
}
switch spec.(type) {
case *ast.TypeSpec:
typeSpec := spec.(*ast.TypeSpec)
switch typeSpec.Type.(type) {
case *ast.StructType:
v := TSStruct{
Name: typeSpec.Name.Name,
Typescript: false,
Fields: []TSSField{},
}
v.getStruct(typeSpec)
if len(v.Fields) == 0 {
continue
}
i.Packages[p].structs[typeSpec.Name.Name] = v
for _, imp := range v.Imports {
a := strings.Split(imp, ".")
if len(a) == 2 {
if _, ok := i.Packages[p].imports[a[0]]; !ok {
i.Packages[p].imports[a[0]] = []string{}
}
if !slices.Contains(i.Packages[p].imports[a[0]], a[1]) {
i.Packages[p].imports[a[0]] = append(i.Packages[p].imports[a[0]], a[1])
}
}
}
case *ast.Ident:
tsInfo := getFieldTsInfo(typeSpec.Type)
t := TSType{
Name: typeSpec.Name.Name,
Typescript: true,
Type: getFieldInfo(typeSpec.Type),
TsType: tsInfo,
dependOn: toBeImported(typeSpec.Type),
}
//fmt.Printf("Type found: %s Type: %s TsType: %s AT: %s\n", t.Name, t.Type, t.TsType, t.SourceInfo)
i.Packages[p].types[typeSpec.Name.Name] = t
default:
// if isType && command != "interface" {
// exitOnError(fmt.Errorf("mismatch delaration for interface %s AT: %s", t.Doc, getSourceInfo(int(typeSpec.Name.NamePos), src)))
// }
tsInfo := getFieldTsInfo(typeSpec.Type)
t := TSType{
Name: typeSpec.Name.Name,
Typescript: true,
Type: getFieldInfo(typeSpec.Type),
TsType: tsInfo,
dependOn: toBeImported(typeSpec.Type),
}
//fmt.Printf("Type found: %s Type: %s TsType: %s AT: %s\n", t.Name, t.Type, t.TsType, t.SourceInfo)
i.Packages[p].types[typeSpec.Name.Name] = t
}
}
}
}
func (i *TSInfo) getConst(p string, c *doc.Value) {
var isTypescript = strings.HasPrefix(c.Doc, "Typescript:")
if isTypescript {
command := strings.TrimPrefix(c.Doc, "Typescript:")
command = strings.TrimSpace(command)
command = strings.Trim(command, "\n")
if strings.Contains(command, "enum=") {
enumName := strings.TrimPrefix(command, "enum=")
enum := TSEnum{
Name: enumName,
Info: []TSEnumInfo{},
}
d := c.Decl
iota := false
iotaValue := 0
for _, s := range d.Specs {
v := s.(*ast.ValueSpec) // safe because decl.Tok == token.CONST
if len(v.Values) > 0 {
be, ok := v.Values[0].(*ast.BinaryExpr)
if ok {
x := be.X.(*ast.BasicLit)
exitOnError(fmt.Errorf("enum binary expression not implemented %s %s %s", x.Value, be.Op.String(), be.Y))
}
ident, ok := v.Values[0].(*ast.Ident)
if ok {
if ident.Name == "iota" {
iota = true
iotaValue = v.Names[0].Obj.Data.(int)
enum.Info = append(enum.Info, TSEnumInfo{Key: v.Names[0].Name, Value: fmt.Sprintf("%d", iotaValue)})
}
}
list, ok := v.Values[0].(*ast.BasicLit)
if ok {
enum.Info = append(enum.Info, TSEnumInfo{Key: v.Names[0].Name, Value: list.Value})
}
} else {
for _, name := range v.Names {
if iota {
iotaValue++
enum.Info = append(enum.Info, TSEnumInfo{Key: name.Name, Value: fmt.Sprintf("%d", iotaValue)})
}
}
}
}
i.Packages[p].enums[enumName] = enum
t1 := TSType{
Name: enumName,
Typescript: true,
Type: "",
TsType: fmt.Sprintf("typeof Enum%s[keyof typeof Enum%s] ", enumName, enumName), //getFieldTsInfo(expr.Type),
dependOn: false,
}
i.Packages[p].types[enumName] = t1
}
if strings.Contains(command, "const") {
d := c.Decl
for _, s := range d.Specs {
v := s.(*ast.ValueSpec)
if len(v.Names) == 0 || len(v.Values) == 0 {
continue
}
c := TSConst{
Name: v.Names[0].Name,
Value: v.Values[0].(*ast.BasicLit).Value,
}
i.Packages[p].consts[c.Name] = c
}
}
if strings.Contains(command, "type") {
fmt.Printf("TypeScript type declaration found in const s, but type parsing is not implemented yet\n")
}
}
}
func (ts *TSInfo) findStruct(p string, n string) bool {
if _, ok := ts.Packages[p]; ok {
@ -127,269 +407,6 @@ func (ts TSInfo) find(p string, n string) bool {
// popola TsInfo con tutte le definizioni dei tipi
func (i *TSInfo) getConst(p string, c *doc.Value) {
var isTypescript = strings.HasPrefix(c.Doc, "Typescript:")
if isTypescript {
command := strings.TrimPrefix(c.Doc, "Typescript:")
command = strings.TrimSpace(command)
command = strings.Trim(command, "\n")
if strings.Contains(command, "type") {
fmt.Printf("TypeScript type declaration found in const s, but type parsing is not implemented yet\n")
}
if strings.Contains(command, "enum=") {
enumName := strings.TrimPrefix(command, "enum=")
enum := TSEnum{
Name: enumName,
Info: []TSEnumInfo{},
}
d := c.Decl
iota := false
iotaValue := 0
for _, s := range d.Specs {
v := s.(*ast.ValueSpec) // safe because decl.Tok == token.CONST
if len(v.Values) > 0 {
be, ok := v.Values[0].(*ast.BinaryExpr)
if ok {
x := be.X.(*ast.BasicLit)
exitOnError(fmt.Errorf("enum binary expression not implemented %s %s %s", x.Value, be.Op.String(), be.Y))
}
ident, ok := v.Values[0].(*ast.Ident)
if ok {
if ident.Name == "iota" {
iota = true
iotaValue = v.Names[0].Obj.Data.(int)
enum.Info = append(enum.Info, TSEnumInfo{Key: v.Names[0].Name, Value: fmt.Sprintf("%d", iotaValue)})
}
}
list, ok := v.Values[0].(*ast.BasicLit)
if ok {
enum.Info = append(enum.Info, TSEnumInfo{Key: v.Names[0].Name, Value: list.Value})
}
} else {
for _, name := range v.Names {
if iota {
iotaValue++
enum.Info = append(enum.Info, TSEnumInfo{Key: name.Name, Value: fmt.Sprintf("%d", iotaValue)})
}
}
}
}
i.Packages[p].enums[enumName] = enum
t1 := TSType{
Name: enumName,
Typescript: false,
Type: "",
TsType: fmt.Sprintf("typeof Enum%s[keyof typeof Enum%s] ", enumName, enumName), //getFieldTsInfo(expr.Type),
dependOn: false,
}
i.Packages[p].types[enumName] = t1
}
if strings.Contains(command, "const") {
d := c.Decl
for _, s := range d.Specs {
v := s.(*ast.ValueSpec)
if len(v.Names) == 0 || len(v.Values) == 0 {
continue
}
c := TSConst{
Name: v.Names[0].Name,
Value: v.Values[0].(*ast.BasicLit).Value,
}
i.Packages[p].consts[c.Name] = c
}
}
if strings.Contains(command, "type") {
fmt.Printf("TypeScript type declaration found in const s, but type parsing is not implemented yet\n")
}
}
}
func (i *TSInfo) getType(p string, t *doc.Type) {
var isTypescript = strings.HasPrefix(t.Doc, "Typescript:")
if isTypescript {
command := strings.TrimPrefix(t.Doc, "Typescript:")
command = strings.TrimSpace(command)
command = strings.Trim(command, "\n")
if strings.Contains(command, "interface") {
fmt.Printf("TypeScript interface declaration found in type s, but interface parsing is not implemented yet\n")
}
if strings.Contains(command, "type") {
fmt.Printf("TypeScript type declaration found in type s, but type parsing is not implemented yet\n")
}
if strings.Contains(command, "enum=") {
fmt.Printf("TypeScript enum declaration found in type s, but enum parsing is not implemented yet\n")
}
}
for _, spec := range t.Decl.Specs {
/* if len(t.Consts) > 0 {
i.getConst(p, t.Consts[0])
continue
} */
switch spec.(type) {
case *ast.TypeSpec:
typeSpec := spec.(*ast.TypeSpec)
switch typeSpec.Type.(type) {
case *ast.StructType:
// if isType && command != "interface" {
// exitOnError(fmt.Errorf("mismatch delaration for interface %s AT: %s", t.Doc, getSourceInfo(int(typeSpec.Name.NamePos), src)))
// }
v := TSStruct{
Name: typeSpec.Name.Name,
Typescript: false,
Fields: []TSSField{},
SourceInfo: "",
}
v.getStruct(typeSpec)
i.Packages[p].structs[typeSpec.Name.Name] = v
default:
// if isType && command != "interface" {
// exitOnError(fmt.Errorf("mismatch delaration for interface %s AT: %s", t.Doc, getSourceInfo(int(typeSpec.Name.NamePos), src)))
// }
tsInfo := getFieldTsInfo(typeSpec.Type)
t := TSType{
Name: typeSpec.Name.Name,
Typescript: false,
Type: getFieldInfo(typeSpec.Type),
TsType: tsInfo,
dependOn: toBeImported(typeSpec.Type),
}
//fmt.Printf("Type found: %s Type: %s TsType: %s AT: %s\n", t.Name, t.Type, t.TsType, t.SourceInfo)
i.Packages[p].types[typeSpec.Name.Name] = t
}
}
}
}
func parseTypescriptDeclarations(n string, pkg TSInfoPakage) {
f, err := os.OpenFile(n, os.O_RDONLY, os.ModePerm)
if err != nil {
log.Fatalf("open file error: %v", err)
return
}
defer f.Close()
l := 1
dat := ""
lines := []TSSourceLine{}
rd := bufio.NewReader(f)
for {
line, err := rd.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
log.Fatalf("read file line error: %v", err)
return
}
lines = append(lines, TSSourceLine{Pos: l, End: l + len(line), Line: l, Source: line})
l++
dat += line
if strings.Contains(line, "// Typescript:") {
if strings.Contains(line, "TStype=") {
p := strings.Index(line, "TStype=")
s := strings.Trim(line[p+len("TStype="):], " ")
a := strings.Split(s, "=")
if len(a) == 2 {
t := TSType{
Name: strings.Trim(a[0], " "),
Typescript: true,
Type: "UserDefined",
TsType: strings.Trim(a[1], " "),
dependOn: false,
}
pkg.types[strings.Trim(a[0], " ")] = t
}
}
if strings.Contains(line, "TSDeclaration=") {
p := strings.Index(line, "TSDeclaration=")
s := strings.Trim(line[p+len("TSDeclaration="):], " ")
a := strings.Split(s, "=")
if len(a) == 2 {
t := TSDec{
Name: strings.Trim(a[0], " "),
Value: strings.Trim(a[1], " "),
}
pkg.decs[strings.Trim(a[0], " ")] = t
}
}
if strings.Contains(line, "TSEndpoint= ") {
e := ParseEndpoint(line, n, l)
if _, ok := pkg.endpoints[e.Name]; ok {
exitOnError(fmt.Errorf("enpoint name %s allready in use: %s", e.Name, line))
}
pkg_info := pkg
pkg_info.endpoints[e.Name] = e
pkg_info.imports = e.Imports
pkg_info.isUsed = true
pkg = pkg_info
}
}
}
}
func (i *TSInfo) Populate(path string) {
i.Packages = make(map[string]TSInfoPakage)
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedSyntax,
Dir: path,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
log.Fatal(err)
}
if packages.PrintErrors(pkgs) > 0 {
log.Fatal("package loading failed")
}
//to get line info for endpoints and typescript declarations
for _, loadedPkg := range pkgs {
pkg := loadedPkg.Name
// skip packages
if pkg == "typescript" || pkg == "tsrpc" {
continue
}
// initialize package info if not exists
if _, ok := i.Packages[pkg]; !ok {
i.Packages[pkg] = TSInfoPakage{structs: make(map[string]TSStruct), types: make(map[string]TSType), enums: make(map[string]TSEnum), consts: make(map[string]TSConst), decs: make(map[string]TSDec), endpoints: make(map[string]TSEndpoint)}
}
// start parsing files
for _, n := range loadedPkg.CompiledGoFiles {
// search for declarative // Typescript: declarations and endpoints
parseTypescriptDeclarations(n, i.Packages[pkg])
}
// parse doc to get all types and consts
docPkg, err := doc.NewFromFiles(loadedPkg.Fset, loadedPkg.Syntax, loadedPkg.PkgPath)
if err != nil {
exitOnError(err)
}
for _, t := range docPkg.Types {
i.getType(pkg, t)
}
for _, c := range docPkg.Consts {
i.getConst(pkg, c)
}
}
}
func (ts *TSInfo) findAvailableStruct(pkg string, n string, deep int) (int, bool) {
a := strings.Split(n, ".")
if n == "" || IsNativeType(n) {
@ -446,11 +463,11 @@ func (i *TSInfo) TestEndpoints() {
for _, v1 := range i.Packages[p].endpoints {
_, f := i.findAvailableStruct(p, v1.Request, 0)
if !f {
fmt.Printf("??Endpoint: request %s not found\n", v1.Request)
fmt.Printf("\n??Endpoint: request %s not found\n", v1.Request)
}
_, f = i.findAvailableStruct(p, v1.Response, 0)
if !f {
fmt.Printf("??Endpoint: response %s not found\n", v1.Response)
fmt.Printf("\n??Endpoint: response %s not found\n", v1.Response)
}
}

View File

@ -6,6 +6,8 @@ package tsrpc
import (
"fmt"
"go/ast"
"slices"
"strings"
)
type TSSField struct {
@ -15,14 +17,13 @@ type TSSField struct {
Json TSTagJson
Ts TSTagTs
DependOn bool
SourceInfo string
}
type TSStruct struct {
Name string
Typescript bool
Fields []TSSField
SourceInfo string
Imports []string
}
func IsNativeType(t string) bool {
@ -137,7 +138,15 @@ func (s *TSStruct) getStruct(ts *ast.TypeSpec) {
Type: getFieldInfo(field.Type),
TsType: tsType,
DependOn: toBeImported(field.Type),
SourceInfo: "",
}
if len(strings.Split(f.Type, ".")) == 2 && tagTs.Type == "" {
a := strings.Split(f.Type, ".")
a[1] = strings.Trim(a[1], "[]*")
a[0] = strings.Trim(a[0], "[]*")
imp := fmt.Sprintf("%s.%s", a[0], a[1])
if !slices.Contains(s.Imports, imp) {
s.Imports = append(s.Imports, imp)
}
}
s.Fields = append(s.Fields, f)
} else {
@ -149,7 +158,6 @@ func (s *TSStruct) getStruct(ts *ast.TypeSpec) {
Type: getFieldInfo(field.Type),
TsType: tsType,
DependOn: toBeImported(field.Type),
SourceInfo: "",
}
s.Fields = append(s.Fields, f)
} else {
@ -161,7 +169,6 @@ func (s *TSStruct) getStruct(ts *ast.TypeSpec) {
Type: se.Name,
TsType: tsType,
DependOn: false,
SourceInfo: "",
}
s.Fields = append(s.Fields, f)
} else {

View File

@ -6,6 +6,7 @@ package tsrpc
import (
"errors"
"fmt"
"slices"
"strings"
)
@ -59,6 +60,9 @@ func (ts *TSSouces) ensurePackage(p string) {
if pkg.Endpoints == nil {
pkg.Endpoints = make(map[string]string)
}
if pkg.Imports == nil {
pkg.Imports = make(map[string][]string)
}
ts.Pakages[p] = pkg
}
@ -147,7 +151,7 @@ func structToTs(info TSInfo, p string, k string) (string, []string, error) {
result += "}\n"
}
if fields == 0 {
return result, dependencies, fmt.Errorf("struct %s not export fields AT: %s", k, info.Packages[p].structs[k].SourceInfo)
return result, dependencies, fmt.Errorf("struct %s not export fields", k)
}
return result, dependencies, nil
}
@ -193,7 +197,7 @@ func (ts *TSSouces) AddDependencies(info TSInfo, p string, s string, dependencie
if info.findStruct(pk, st) {
if emptySrtuct(info, pk, st) {
ts.Errors = append(ts.Errors, fmt.Sprintf("Empty struct %s.%s AT: %s", pk, st, info.Packages[p].structs[s].SourceInfo))
ts.Errors = append(ts.Errors, fmt.Sprintf("Empty struct %s.%s ", pk, st))
}
s, d, err := structToTs(info, pk, st)
if err != nil {
@ -209,14 +213,14 @@ func (ts *TSSouces) AddDependencies(info TSInfo, p string, s string, dependencie
ts.ensurePackage(pk)
ts.Pakages[pk].Types[st] = s
} else {
ts.Errors = append(ts.Errors, fmt.Sprintf("Dipendence not found %s.%s AT: %s", pk, st, info.Packages[p].structs[s].SourceInfo))
ts.Errors = append(ts.Errors, fmt.Sprintf("Dipendence not found %s.%s", pk, st))
}
}
}
}
func (ts *TSSouces) Populate(info TSInfo) {
func (ts *TSSouces) BuildTSSources(info TSInfo) {
ts.Pakages = make(map[string]TSModule)
ts.Errors = []string{}
for p := range info.Packages {
@ -227,7 +231,7 @@ func (ts *TSSouces) Populate(info TSInfo) {
for _, st := range info.Packages[p].structs {
if st.Typescript {
if len(st.Fields) == 0 {
ts.Errors = append(ts.Errors, fmt.Sprintf("Empty struct %s.%s AT: %s", p, st.Name, info.Packages[p].structs[st.Name].SourceInfo))
ts.Errors = append(ts.Errors, fmt.Sprintf("Empty struct %s.%s ", p, st.Name))
}
s, dependencies, err := structToTs(info, p, st.Name)
if err != nil {
@ -253,9 +257,9 @@ func (ts *TSSouces) Populate(info TSInfo) {
}
for _, t := range info.Packages[p].types {
if t.Typescript {
ts.Pakages[p].Types[t.Name] = fmt.Sprintf("export type %s = %s\n", t.Name, t.TsType)
}
}
for _, e := range info.Packages[p].endpoints {
@ -280,5 +284,16 @@ func (ts *TSSouces) Populate(info TSInfo) {
pkg.Endpoints[e.Name] = e.ToTs()
ts.Pakages[p] = pkg
}
for pk, t := range info.Packages[p].imports {
pkg := ts.Pakages[p]
if _, ok := pkg.Imports[pk]; !ok {
for _, v := range t {
if !slices.Contains(pkg.Imports[pk], v) {
pkg.Imports[pk] = append(pkg.Imports[pk], v)
}
}
}
}
}
}

View File

@ -16,6 +16,12 @@ var TSReport = ""
var tsFiles = TSFiles{}
type ExcudedPackages []string
func (ip *TSInfoPakage) MakeMaps() {
*ip = TSInfoPakage{structs: make(map[string]TSStruct), types: make(map[string]TSType), enums: make(map[string]TSEnum), consts: make(map[string]TSConst), decs: make(map[string]TSDec), endpoints: make(map[string]TSEndpoint), imports: make(map[string][]string), isUsed: false}
}
func GetTSSource() error {
path := ""
if value, exists := os.LookupEnv("TS_GENERATOR_PATH"); exists {
@ -26,9 +32,9 @@ func GetTSSource() error {
var tsInfoData = TSInfo{}
var tsSourcesData = TSSouces{}
tsInfoData.Populate(path)
tsInfoData.Populate(path, ExcudedPackages{"Typescript", "tsrpc"})
tsInfoData.TestEndpoints()
tsSourcesData.Populate(tsInfoData)
tsSourcesData.BuildTSSources(tsInfoData)
if len(tsSourcesData.Errors) != 0 {
err := ""
@ -119,6 +125,7 @@ func GetTSSource() error {
for f := range tsSourcesData.Pakages[p].Imports {
imports += "import type * as " + f + " from './" + f + ".ts'\n"
}
tmp += imports
tmp += source
tsFiles.Add(p+".ts", tmp)
@ -128,7 +135,6 @@ func GetTSSource() error {
err = tsFiles.Save()
if err != nil {
fmt.Printf("save ts files: %s\n", err)
}
return err
}

View File

@ -2,3 +2,4 @@
GO_PROJECT_DIR=/Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/http/static
# Option B (overrides GO_PROJECT_DIR): explicit target dir where dist/spa is copied
# GO_SPA_TARGET_DIR=/absolute/path/to/your/go/project/spa
SEED=0

View File

@ -116,7 +116,7 @@ export default defineConfig((ctx) => {
// directives: [],
// Quasar plugins
plugins: ['Notify', 'Dialog', 'Loading'],
plugins: ['Notify', 'Dialog', 'Loading', 'AppVisibility'],
},
// animations: 'all', // --- includes all animations

View File

@ -5,11 +5,14 @@
<script setup lang="ts">
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'
import { resolveI18nLocale, usePreferencesStore } from 'src/stores/preferences-store';
import { useUserStore } from 'src/stores/user-store';
const { locale } = useI18n();
const preferencesStore = usePreferencesStore();
const userStore = useUserStore();
const $q = useQuasar()
watch(
() => preferencesStore.language,
@ -18,4 +21,16 @@ watch(
},
{ immediate: true },
);
watch(
() => $q.appVisible,
(state) => {
console.log(`App became ${state ? 'visible' : 'hidden'}`)
if (state) {
void userStore.getUser();
// App is visible, you can perform actions here if needed
} else {
// App is hidden, you can perform actions here if needed
}
})
</script>

View File

@ -4,7 +4,7 @@ import type * as users from "./users.ts";
// Typescript: TSEndpoint= path=/api/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=admin.ListUsersResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 11
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 13
export const listUsers = async (
data: ListUsersRequest,
): Promise<{ data: ListUsersResponse; error: Nullable<string> }> => {
@ -16,7 +16,7 @@ export const listUsers = async (
// Typescript: TSEndpoint= path=/api/admin/users/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 14
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 18
export const blockUser = async (
data: BlockUserRequest,
): Promise<{ data: users.User; error: Nullable<string> }> => {
@ -26,6 +26,42 @@ export const blockUser = async (
};
};
// Typescript: TSEndpoint= path=/api/admin/updateUser; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 23
export const updateUser = async (
data: users.UpdateUserRequest,
): Promise<{ data: users.User; error: Nullable<string> }> => {
return (await api.PUT("/api/admin/updateUser", data)) as {
data: users.User;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/admin/updateuserdetails; name=adminUpdateUserDetails; method=PUT; request=users.UpdateUserDetailsRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 28
export const adminUpdateUserDetails = async (
data: users.UpdateUserDetailsRequest,
): Promise<{ data: users.User; error: Nullable<string> }> => {
return (await api.PUT("/api/admin/updateuserdetails", data)) as {
data: users.User;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/admin/updateuserpreferences; name=adminUpdateUserPreferences; method=PUT; request=users.UpdateUserPreferencesRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 33
export const adminUpdateUserPreferences = async (
data: users.UpdateUserPreferencesRequest,
): Promise<{ data: users.User; error: Nullable<string> }> => {
return (await api.PUT("/api/admin/updateuserpreferences", data)) as {
data: users.User;
error: Nullable<string>;
};
};
export interface ListUsersResponse {
items: users.User[];
page: number;
@ -33,7 +69,7 @@ export interface ListUsersResponse {
}
export interface BlockUserRequest {
uuid: string;
id: string;
action: string;
}

View File

@ -4,7 +4,7 @@
//
// This file was generated by github.com/millevolte/ts-rpc
//
// Apr 27, 2026 22:35:43 UTC
// May 08, 2026 12:28:15 UTC
//
export interface ApiRestResponse {

View File

@ -1,4 +1,4 @@
// API Declarations
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
export type Nullable<T> = T | null;
export type Record<K extends string | number | symbol, T> = { [P in K]: T };

View File

@ -1,21 +1,12 @@
import { api } from "./api";
import type { Nullable, Record } from "./apiTypes.ts";
export type UserRole = (typeof EnumUserRole)[keyof typeof EnumUserRole];
// Typescript: TSEndpoint= path=/api/roles; name=getRoles; method=GET; response=auth.AllRoles
export type RolesData = string;
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/auth/routes.go Line: 7
export const getRoles = async (): Promise<{
data: AllRoles;
error: Nullable<string>;
}> => {
return (await api.GET("/api/roles")) as {
data: AllRoles;
error: Nullable<string>;
};
};
export interface AllRoles {
roles: Record<string, string>;
}
export type Permission = string;
export const EnumUserRole = {
SuperAdminRole: "superadmin",
AdminRole: "admin",
ManagerRole: "manager",
ContentCreatorRole: "content_creator",
UserRole: "user",
GuestRole: "guest",
} as const;

View File

@ -1,12 +1,11 @@
import { api } from "./api";
import type { Nullable } from "./apiTypes.ts";
import type * as responses from "./responses.ts";
import type * as tokens from "./tokens.ts";
import type * as auth from "./auth.ts";
import type * as responses from "./responses.ts";
// Typescript: TSEndpoint= path=/api/auth/me; name=me; method=GET; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 40
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 43
export const me = async (): Promise<{
data: User;
error: Nullable<string>;
@ -17,57 +16,9 @@ export const me = async (): Promise<{
};
};
// Typescript: TSEndpoint= path=/api/auth/refresh; name=refresh; method=POST; request=users.RefreshRequest; response=tokens.TokenPair
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 46
export const refresh = async (
data: RefreshRequest,
): Promise<{ data: tokens.TokenPair; error: Nullable<string> }> => {
return (await api.POST("/api/auth/refresh", data)) as {
data: tokens.TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/password/reset; name=resetPassword; method=POST; request=users.ResetPasswordRequest; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 55
export const resetPassword = async (
data: ResetPasswordRequest,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/api/auth/password/reset", data)) as {
data: responses.SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/users/:uuid; name=getUser; method=GET; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 28
export const getUser = async (
uuid: string,
): Promise<{ data: User; error: Nullable<string> }> => {
return (await api.GET(`/api/users/${uuid}`)) as {
data: User;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/users/update; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 34
export const updateUser = async (
data: UpdateUserRequest,
): Promise<{ data: User; error: Nullable<string> }> => {
return (await api.PUT("/api/users/update", data)) as {
data: User;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/password/update; name=updatePassword; method=PUT; request=users.UpdatePasswordRequest; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 61
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 64
export const updatePassword = async (
data: UpdatePasswordRequest,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
@ -77,30 +28,6 @@ export const updatePassword = async (
};
};
// Typescript: TSEndpoint= path=/api/users/:uuid; name=deleteUser; method=DELETE; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 37
export const deleteUser = async (
uuid: string,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
return (await api.DELETE(`/api/users/${uuid}`)) as {
data: responses.SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/password/forgot; name=forgotPassword; method=POST; request=users.ForgotPasswordRequest; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 52
export const forgotPassword = async (
data: ForgotPasswordRequest,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/api/auth/password/forgot", data)) as {
data: responses.SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/users; name=createUser; method=POST; request=users.UserCreateRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 31
@ -113,21 +40,45 @@ export const createUser = async (
};
};
// Typescript: TSEndpoint= path=/api/auth/login; name=login; method=POST; request=users.LoginRequest; response=tokens.TokenPair
// Typescript: TSEndpoint= path=/api/users/update/details; name=updateUserDetails; method=PUT; request=users.UpdateUserDetailsRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 43
export const login = async (
data: LoginRequest,
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 37
export const updateUserDetails = async (
data: UpdateUserDetailsRequest,
): Promise<{ data: User; error: Nullable<string> }> => {
return (await api.PUT("/api/users/update/details", data)) as {
data: User;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/refresh; name=refresh; method=POST; request=users.RefreshRequest; response=tokens.TokenPair
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 49
export const refresh = async (
data: RefreshRequest,
): Promise<{ data: tokens.TokenPair; error: Nullable<string> }> => {
return (await api.POST("/api/auth/login", data)) as {
return (await api.POST("/api/auth/refresh", data)) as {
data: tokens.TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/password/reset; name=resetPassword; method=POST; request=users.ResetPasswordRequest; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 58
export const resetPassword = async (
data: ResetPasswordRequest,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/api/auth/password/reset", data)) as {
data: responses.SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/register; name=register; method=POST; request=users.UserCreateRequest; response=users.User
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 49
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 52
export const register = async (
data: UserCreateRequest,
): Promise<{ data: User; error: Nullable<string> }> => {
@ -137,9 +88,45 @@ export const register = async (
};
};
// Typescript: TSEndpoint= path=/api/users/:id; name=deleteUser; method=DELETE; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 40
export const deleteUser = async (
id: string,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
return (await api.DELETE(`/api/users/${id}`)) as {
data: responses.SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/login; name=login; method=POST; request=users.LoginRequest; response=tokens.TokenPair
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 46
export const login = async (
data: LoginRequest,
): Promise<{ data: tokens.TokenPair; error: Nullable<string> }> => {
return (await api.POST("/api/auth/login", data)) as {
data: tokens.TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/password/forgot; name=forgotPassword; method=POST; request=users.ForgotPasswordRequest; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 55
export const forgotPassword = async (
data: ForgotPasswordRequest,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/api/auth/password/forgot", data)) as {
data: responses.SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/api/auth/password/valid; name=validToken; method=POST; request=string; response=responses.SimpleResponse
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 58
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 61
export const validToken = async (
data: string,
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
@ -149,43 +136,58 @@ export const validToken = async (
};
};
export interface ForgotPasswordRequest {
email: string;
}
// Typescript: TSEndpoint= path=/api/users/:id; name=getUser; method=GET; response=users.User
export interface LoginRequest {
username: string;
password: string;
}
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 28
export const getUser = async (
id: string,
): Promise<{ data: User; error: Nullable<string> }> => {
return (await api.GET(`/api/users/${id}`)) as {
data: User;
error: Nullable<string>;
};
};
export interface UpdateUserRequest {
uuid: string;
name: string;
email: string;
password: string;
permission: auth.Permission;
status: UserStatus;
types: UserTypes;
avatar: Nullable<string>;
details: Nullable<UserDetails>;
preferences: Nullable<UserPreferences>;
}
// Typescript: TSEndpoint= path=/api/users/update; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.User
export interface UserCreateRequest {
name: string;
email: string;
password: string;
permission: auth.Permission;
status: UserStatus;
types: UserTypes;
avatar: Nullable<string>;
details: Nullable<UserDetails>;
preferences: Nullable<UserPreferences>;
}
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 34
export const updateUser = async (
data: UpdateUserRequest,
): Promise<{ data: User; error: Nullable<string> }> => {
return (await api.PUT("/api/users/update", data)) as {
data: User;
error: Nullable<string>;
};
};
export interface UserDetails {
id: number;
userId: number;
userId: string;
title: string;
firstName: string;
lastName: string;
address: string;
city: string;
zipCode: string;
country: string;
phone: string;
createdAt?: Date;
updatedAt?: Date;
}
export interface UpdateUserRequest {
id: string;
name: string;
email: string;
password: string;
permission: string;
status: UserStatus;
type: UserType;
}
export interface UpdateUserDetailsRequest {
id: number;
userId: string;
title: string;
firstName: string;
lastName: string;
@ -194,42 +196,15 @@ export interface UserDetails {
zipCode: string;
country: string;
phone: string;
createdAt: Nullable<Date>;
updatedAt: Nullable<Date>;
}
export interface RefreshRequest {
refresh_token: string;
}
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface User {
export interface UpdateUserPreferencesRequest {
id: number;
email: string;
name: string;
permission: auth.Permission;
types: UserTypes;
status: UserStatus;
activatedAt: Nullable<Date>;
uuid: string;
details: Nullable<UserDetails>;
preferences: Nullable<UserPreferences>;
avatar: Nullable<string>;
createdAt: Nullable<Date>;
updatedAt: Nullable<Date>;
}
export interface UpdatePasswordRequest {
password: string;
}
export interface UserPreferences {
id: number;
userId: number;
userId: string;
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
@ -238,10 +213,83 @@ export interface UserPreferences {
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
createdAt: Nullable<Date>;
updatedAt: Nullable<Date>;
}
export interface UserCreateRequest {
name: string;
email: string;
password: string;
permission: string;
status: UserStatus;
type: UserType;
avatar?: Nullable<string>;
details?: Nullable<UserDetails>;
preferences?: Nullable<UserPreferences>;
}
export interface UpdatePasswordRequest {
id: string;
password: string;
}
export interface User {
id: string;
email: string;
name: string;
permission: string;
type: UserType;
status: UserStatus;
details: Nullable<UserDetails>;
preferences: Nullable<UserPreferences>;
avatar: Nullable<string>;
activatedAt: Nullable<Date>;
createdAt?: Date;
updatedAt?: Date;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface UserPreferences {
id: number;
userId: string;
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
createdAt?: Date;
updatedAt?: Date;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface ResetPasswordRequest {
token: string;
password: string;
}
export type UserTypes = (typeof EnumUserTypes)[keyof typeof EnumUserTypes];
export type UserStatus = string;
export type UserTypes = string[];
export type UserType = string;
export const EnumUserStatus = {
UserStatusPending: "pending",
UserStatusActive: "active",
UserStatusDisabled: "disabled",
} as const;
export const EnumUserTypes = {
UserTypeInternal: "internal",
UserTypeExternal: "external",
UserTypeSurveyor: "surveyor",
} as const;

View File

@ -46,6 +46,10 @@
</q-item-section>
</q-item>
</q-list>
<div class="q-pa-md">
{{userStore.userData ? `Logged in as: ${userStore.userData} ` : 'Not logged in'}}<br>
<q-btn flat color="primary" label="Logout" @click="userStore.clearUser()" />
</div>
</q-drawer>
<q-page-container>
@ -55,13 +59,28 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUserStore } from 'src/stores/user-store';
const { t } = useI18n();
const router = useRouter();
const leftDrawerOpen = ref(false);
const userStore = useUserStore();
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
watch(
() => userStore.userCounter,
() => {
if (!userStore.userData) {
void router.push('/')
}
},
);
</script>

View File

@ -117,25 +117,25 @@
<q-btn flat round dense icon="more_vert" color="grey-8">
<q-menu anchor="bottom right" self="top right">
<q-list dense class="user-action-menu">
<q-item clickable v-close-popup @click="openViewDialog(props.row.uuid)">
<q-item clickable v-close-popup @click="openViewDialog(props.row.id)">
<q-item-section avatar>
<q-icon name="visibility" />
</q-item-section>
<q-item-section>Show</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openEditDialog(props.row.uuid)">
<q-item clickable v-close-popup @click="openEditDialog(props.row.id)">
<q-item-section avatar>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openAvatarDialog(props.row.uuid)">
<q-item clickable v-close-popup @click="openAvatarDialog(props.row.id)">
<q-item-section avatar>
<q-icon name="add_a_photo" />
</q-item-section>
<q-item-section>Edit avatar</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openPasswordDialog(props.row.uuid)">
<q-item clickable v-close-popup @click="openPasswordDialog(props.row.id)">
<q-item-section avatar>
<q-icon name="password" />
</q-item-section>
@ -167,415 +167,68 @@
</q-card>
</div>
<q-dialog v-model="dialogOpen">
<q-card class="editor-card modal-card">
<q-form class="form-grid" @submit.prevent="saveUser">
<q-card-section class="editor-header">
<div class="editor-toolbar">
<div class="editor-headline">
<div class="text-overline">{{ dialogMode === 'create' ? 'Nuovo utente' : dialogMode === 'edit' ? 'Modifica utente' : 'Dettaglio utente' }}</div>
<div class="text-h5">{{ form.name || 'Profilo utente' }}</div>
<div class="text-caption">{{ form.email || 'Compila i dati di base' }}</div>
</div>
</div>
<div class="editor-toolbar-actions">
<q-btn flat color="white" label="Chiudi" v-close-popup />
<q-btn
v-if="dialogMode !== 'view'"
color="white"
text-color="primary"
unelevated
:loading="saving"
:label="dialogMode === 'create' ? 'Crea utente' : 'Salva modifiche'"
type="submit"
/>
</div>
<q-tabs
v-model="editorTab"
dense
align="left"
inline-label
active-color="primary"
indicator-color="primary"
class="editor-tabs"
>
<q-tab name="account" icon="person" label="Account" />
<q-tab name="details" icon="badge" label="Details" />
<q-tab name="preferences" icon="tune" label="Preferences" />
</q-tabs>
</q-card-section>
<q-separator />
<q-card-section class="editor-body">
<q-tab-panels v-model="editorTab" animated class="editor-panels">
<q-tab-panel name="account">
<section class="form-section">
<h2>Account</h2>
<div class="section-grid">
<q-input v-model="form.name" outlined label="Nome" :readonly="dialogMode === 'view'" />
<q-input v-model="form.email" outlined label="Email" type="email" :readonly="dialogMode === 'view'" />
<q-input
v-if="dialogMode === 'create'"
v-model="form.password"
outlined
label="Password"
type="password"
hint="Minimo 8 caratteri"
/>
<div v-if="dialogMode !== 'create'" class="avatar-inline-card span-2">
<div class="avatar-inline-preview">
<img v-if="form.avatar" :src="form.avatar" :alt="form.name" />
<span v-else>{{ avatarInitials(form) }}</span>
</div>
<div class="avatar-inline-meta">
<div class="text-subtitle2">Avatar</div>
<div class="text-caption text-grey-7">
{{ form.avatar ? 'Avatar profilo impostato' : 'Nessun avatar impostato' }}
</div>
</div>
<q-btn
v-if="dialogMode === 'edit'"
flat
color="primary"
icon="add_a_photo"
label="Modifica"
@click="openAvatarDialog(form.uuid)"
/>
</div>
<q-input v-else v-model="form.avatar" outlined label="Avatar URL" />
<q-select
v-model="form.status"
outlined
label="Status"
:options="statusOptions"
:readonly="dialogMode === 'view'"
/>
<q-select
v-model="form.permission"
outlined
multiple
use-input
use-chips
new-value-mode="add-unique"
label="Roles"
:options="roleOptions"
:readonly="dialogMode === 'view'"
/>
<q-select
v-model="form.types"
outlined
multiple
use-input
use-chips
new-value-mode="add-unique"
label="Types"
:options="typeOptions"
:readonly="dialogMode === 'view'"
/>
</div>
</section>
</q-tab-panel>
<q-tab-panel name="details">
<section class="form-section">
<div class="section-heading">
<h2>Details</h2>
<q-toggle
v-model="detailsEnabled"
label="Abilita details"
:disable="dialogMode === 'view'"
/>
</div>
<div class="section-grid" :class="{ disabled: !detailsEnabled }">
<q-input v-model="form.details.title" outlined label="Title" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.firstName" outlined label="First name" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.lastName" outlined label="Last name" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.phone" outlined label="Phone" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.address" outlined label="Address" class="span-2" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.city" outlined label="City" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.zipCode" outlined label="Zip code" :disable="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="form.details.country" outlined label="Country" :disable="!detailsEnabled || dialogMode === 'view'" />
</div>
</section>
</q-tab-panel>
<q-tab-panel name="preferences">
<section class="form-section">
<div class="section-heading">
<h2>Preferences</h2>
<q-toggle
v-model="preferencesEnabled"
label="Abilita preferences"
:disable="dialogMode === 'view'"
/>
</div>
<div class="section-grid" :class="{ disabled: !preferencesEnabled }">
<q-input v-model="form.preferences.language" outlined label="Language" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-input
v-model.number="form.preferences.idleTimeout"
outlined
type="number"
label="Idle timeout"
:disable="!preferencesEnabled || dialogMode === 'view'"
/>
<q-input v-model="form.preferences.idlePin" outlined label="Idle pin" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="form.preferences.useIdle" label="Use idle" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="form.preferences.useIdlePassword" label="Use idle password" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="form.preferences.useDirectLogin" label="Use direct login" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="form.preferences.useQuadcodeLogin" label="Use quadcode login" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="form.preferences.sendNoticesMail" label="Send notices mail" :disable="!preferencesEnabled || dialogMode === 'view'" />
</div>
</section>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="passwordDialogOpen">
<q-card class="password-card modal-card">
<q-card-section>
<div class="text-overline text-primary">Change password</div>
<div class="text-h6">{{ passwordDialogUserEmail || 'User' }}</div>
</q-card-section>
<q-separator />
<q-card-section class="password-grid">
<q-input
v-model="passwordForm.password"
outlined
type="password"
label="New password"
hint="Minimo 8 caratteri"
/>
<q-input
v-model="passwordForm.confirmPassword"
outlined
type="password"
label="Confirm password"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="grey-7" label="Chiudi" v-close-popup />
<q-btn
color="primary"
label="Salva password"
:loading="saving"
@click="savePasswordChange"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="blockDialogOpen">
<q-card class="password-card modal-card">
<q-card-section>
<div class="text-overline text-primary">User access</div>
<div class="text-h6">{{ blockDialogUser.email || 'User' }}</div>
<div class="text-caption">
Stato attuale: {{ blockDialogUser.status || 'n/a' }}
</div>
</q-card-section>
<q-separator />
<q-card-section class="password-grid">
<q-toggle
v-model="blockDialogBlocked"
checked-icon="block"
unchecked-icon="lock_open"
color="negative"
:label="blockDialogBlocked ? 'Utente bloccato' : 'Utente attivo'"
/>
<div class="text-body2 text-grey-7">
{{
blockDialogBlocked
? 'Lutente non potra piu accedere finche non verra sbloccato.'
: 'Lutente potra accedere normalmente.'
}}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="grey-7" label="Chiudi" v-close-popup />
<q-btn
color="primary"
:loading="saving"
:label="blockDialogBlocked ? 'Salva blocco' : 'Salva sblocco'"
@click="saveBlockState"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="avatarDialogOpen">
<q-card class="editor-card modal-card">
<q-card-section class="editor-header">
<div class="editor-toolbar">
<div class="editor-headline">
<div class="text-overline">Avatar editor</div>
<div class="text-h5">{{ avatarDialogUser.email || 'User avatar' }}</div>
<div class="text-caption">Ritaglio circolare per il profilo utente</div>
</div>
</div>
<div class="editor-toolbar-actions">
<q-btn flat color="white" label="Chiudi" v-close-popup />
<q-btn
color="white"
text-color="primary"
unelevated
:disable="!avatarSource"
:loading="saving"
label="Salva avatar"
@click="saveAvatar"
/>
</div>
</q-card-section>
<q-separator />
<q-card-section class="avatar-editor-body">
<div class="avatar-toolbar">
<input
ref="avatarFileInputRef"
class="visually-hidden"
type="file"
accept="image/*"
@change="onAvatarFileSelected"
<UserEditorDialog
v-model="dialogOpen"
:dialog-mode="dialogMode"
:user-id="editorDialogUserId"
@saved="loadUsers"
@open-avatar="openAvatarDialog"
/>
<q-btn
color="primary"
icon="upload"
label="Carica immagine"
@click="openAvatarFilePicker"
<UserPasswordDialog
v-model="passwordDialogOpen"
:user-id="passwordDialogUserId"
@saved="loadUsers"
/>
<div class="avatar-file-name">
{{ avatarFile?.name || 'Nessun file selezionato' }}
</div>
<q-btn
flat
color="primary"
icon="restart_alt"
label="Reset crop"
:disable="!avatarSource"
@click="resetAvatarCrop"
<UserBlockDialog
v-model="blockDialogOpen"
:user-id="blockDialogUserId"
@saved="loadUsers"
/>
</div>
<div class="avatar-editor-grid">
<div class="avatar-cropper-shell">
<div v-if="avatarSource" class="avatar-cropper-box">
<VuePictureCropper
ref="avatarCropperRef"
:img="avatarSource"
:box-style="avatarCropperBoxStyle"
:options="avatarCropperOptions"
:preset-mode="avatarPresetMode"
<UserAvatarDialog
v-model="avatarDialogOpen"
:user-id="avatarDialogUserId"
@saved="loadUsers"
/>
</div>
<div v-else class="avatar-empty-state">
Seleziona unimmagine per modificare lavatar.
</div>
</div>
<div class="avatar-preview-shell">
<div class="avatar-preview-title">Anteprima</div>
<div class="avatar-preview-disc">
<img v-if="avatarPreview" :src="avatarPreview" alt="Avatar preview" />
<span v-else>No avatar</span>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useQuasar, type QTableColumn, type QTableProps } from 'quasar';
import VuePictureCropper from 'vue-picture-cropper';
import UserAvatarDialog from './dialogs/UserAvatarDialog.vue';
import UserBlockDialog from './dialogs/UserBlockDialog.vue';
import UserEditorDialog from './dialogs/UserEditorDialog.vue';
import UserPasswordDialog from './dialogs/UserPasswordDialog.vue';
import type { DialogMode } from './dialogs/types';
import {
blockUser,
listUsers,
type BlockUserRequest,
type ListUsersRequest,
}from 'src/api/admin';
import {
createUser,
getUser,
updateUser,
EnumUserStatus,
type UpdateUserRequest,
type UserCreateRequest,
type User,
type UserStatus,
type UserDetails,
type UserPreferences,
} from 'src/api/users';
type DialogMode = 'create' | 'edit' | 'view';
type UserDetailsForm = Omit<UserDetails, 'id' | 'userId' | 'createdAt' | 'updatedAt'>;
type UserPreferencesForm = Omit<UserPreferences, 'id' | 'userId' | 'createdAt' | 'updatedAt'>;
interface UserFormState {
uuid: string;
name: string;
email: string;
password: string;
status: UserStatus;
permission: string;
types: string[];
avatar: string;
details: UserDetailsForm;
preferences: UserPreferencesForm;
}
const $q = useQuasar();
const loading = ref(false);
const saving = ref(false);
const dialogOpen = ref(false);
const passwordDialogOpen = ref(false);
const blockDialogOpen = ref(false);
const avatarDialogOpen = ref(false);
const dialogMode = ref<DialogMode>('create');
const editorTab = ref<'account' | 'details' | 'preferences'>('account');
const editorDialogUserId = ref('');
const passwordDialogUserId = ref('');
const blockDialogUserId = ref('');
const avatarDialogUserId = ref('');
const filter = ref('');
const rows = ref<User[]>([]);
const detailsEnabled = ref(true);
const preferencesEnabled = ref(true);
const passwordDialogUserUuid = ref('');
const passwordDialogUserEmail = ref('');
const blockDialogUser = reactive({
uuid: '',
email: '',
status: '' as UserStatus | '',
});
const blockDialogBlocked = ref(false);
const avatarDialogUser = reactive({
uuid: '',
email: '',
});
const avatarFile = ref<File | null>(null);
const avatarFileInputRef = ref<HTMLInputElement | null>(null);
const avatarSource = ref('');
const avatarPreview = ref('');
const avatarCropperRef = ref<{ cropper?: { getDataURL?: (options?: { width?: number; height?: number; rounded?: boolean }) => string; reset?: () => void } } | null>(null);
const pagination = ref<QTableProps['pagination']>({
sortBy: 'name',
descending: false,
@ -584,217 +237,25 @@ const pagination = ref<QTableProps['pagination']>({
rowsNumber: 0,
});
const statusOptions = Object.values(EnumUserStatus);
const roleOptions = ['admin', 'manager', 'user'];
const typeOptions = ['internal', 'external'];
const avatarCropperBoxStyle = {
width: '100%',
height: '100%',
backgroundColor: '#f3f7fb',
margin: '0 auto',
};
const avatarPresetMode = {
mode: 'round',
width: 320,
height: 320,
} as const;
const columns: QTableColumn<User>[] = [
{ name: 'name', label: 'Utente', field: 'name', align: 'left', sortable: true },
{ name: 'status', label: 'Status', field: 'status', align: 'left', sortable: true },
{ name: 'permission', label: 'Roles', field: (row) => row.permission, align: 'left' },
{ name: 'details', label: 'Details', field: (row) => fullName(row), align: 'left' },
{ name: 'preferences', label: 'Preferences', field: (row) => row.preferences?.language ?? '', align: 'left' },
{ name: 'actions', label: '', field: 'uuid', align: 'right' },
{ name: 'actions', label: '', field: 'id', align: 'right' },
];
const form = reactive<UserFormState>(emptyForm());
const passwordForm = reactive({
password: '',
confirmPassword: '',
});
const payload = computed<UserCreateRequest | UpdateUserRequest>(() => ({
name: form.name.trim(),
email: form.email.trim(),
password: dialogMode.value === 'create' ? form.password : '',
status: form.status,
// TODO: permission management
//permission: sanitizeList(form.permission),
permission: form.permission,
types: sanitizeList(form.types),
avatar: normalizeNullableString(form.avatar),
details: detailsEnabled.value ? sanitizeDetails(form.details) : null,
preferences: preferencesEnabled.value ? sanitizePreferences(form.preferences) : null,
}));
onMounted(async () => {
await loadUsers();
const err = await loadUsers();
if (!err) {
$q.notify({
type: 'negative',
message: 'Errore durante il caricamento degli utenti',
});
}
});
function emptyForm(): UserFormState {
return {
uuid: '',
name: '',
email: '',
password: '',
status: EnumUserStatus.UserStatusPending,
permission: "user",
types: ['internal'],
avatar: '',
details: {
title: '',
firstName: '',
lastName: '',
address: '',
city: '',
zipCode: '',
country: '',
phone: '',
},
preferences: {
useIdle: false,
idleTimeout: 0,
useIdlePassword: false,
idlePin: '',
useDirectLogin: false,
useQuadcodeLogin: false,
sendNoticesMail: false,
language: 'it',
},
};
}
function resetForm(user?: User): void {
const next = user ? mapUserToForm(user) : emptyForm();
Object.assign(form, next);
detailsEnabled.value = !!user?.details || !user;
preferencesEnabled.value = !!user?.preferences || !user;
}
function mapUserToForm(user: User): UserFormState {
return {
uuid: user.uuid,
name: user.name,
email: user.email,
password: '',
status: user.status,
permission: user.permission,
types: [...user.types],
avatar: user.avatar ?? '',
details: {
title: user.details?.title ?? '',
firstName: user.details?.firstName ?? '',
lastName: user.details?.lastName ?? '',
address: user.details?.address ?? '',
city: user.details?.city ?? '',
zipCode: user.details?.zipCode ?? '',
country: user.details?.country ?? '',
phone: user.details?.phone ?? '',
},
preferences: {
useIdle: user.preferences?.useIdle ?? false,
idleTimeout: user.preferences?.idleTimeout ?? 0,
useIdlePassword: user.preferences?.useIdlePassword ?? false,
idlePin: user.preferences?.idlePin ?? '',
useDirectLogin: user.preferences?.useDirectLogin ?? false,
useQuadcodeLogin: user.preferences?.useQuadcodeLogin ?? false,
sendNoticesMail: user.preferences?.sendNoticesMail ?? false,
language: user.preferences?.language ?? 'it',
},
};
}
function sanitizeList(values: string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean);
}
function normalizeNullableString(value: string): string | null {
const normalized = value.trim();
return normalized === '' ? null : normalized;
}
function sanitizeDetails(value: UserDetailsForm): UserDetails | null {
const normalized: UserDetailsForm = {
title: value.title.trim(),
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
address: value.address.trim(),
city: value.city.trim(),
zipCode: value.zipCode.trim(),
country: value.country.trim(),
phone: value.phone.trim(),
};
return Object.values(normalized).some(Boolean) ? normalized as UserDetails : null;
}
function sanitizePreferences(value: UserPreferencesForm): UserPreferences | null {
const normalized: UserPreferencesForm = {
useIdle: value.useIdle,
idleTimeout: Number(value.idleTimeout) || 0,
useIdlePassword: value.useIdlePassword,
idlePin: value.idlePin.trim(),
useDirectLogin: value.useDirectLogin,
useQuadcodeLogin: value.useQuadcodeLogin,
sendNoticesMail: value.sendNoticesMail,
language: value.language.trim(),
};
const hasData =
normalized.useIdle ||
normalized.idleTimeout > 0 ||
normalized.useIdlePassword ||
normalized.idlePin !== '' ||
normalized.useDirectLogin ||
normalized.useQuadcodeLogin ||
normalized.sendNoticesMail ||
normalized.language !== '';
return hasData ? normalized as UserPreferences : null;
}
function syncAvatarPreview(): void {
avatarPreview.value =
avatarCropperRef.value?.cropper?.getDataURL?.({
width: 220,
height: 220,
rounded: true,
}) || avatarSource.value;
}
const avatarCropperOptions = {
viewMode: 1 as const,
dragMode: 'move' as const,
aspectRatio: 1,
autoCropArea: 0.9,
background: false,
movable: true,
zoomable: true,
scalable: false,
guides: false,
ready: () => {
syncAvatarPreview();
},
crop: () => {
syncAvatarPreview();
},
};
function buildUpdatePayload(user: User, password = ''): UpdateUserRequest {
return {
uuid: user.uuid,
name: user.name,
email: user.email,
password,
status: user.status,
permission: user.permission,
types: [...user.types],
avatar: user.avatar ?? null,
details: user.details ? { ...user.details } : null,
preferences: user.preferences ? { ...user.preferences } : null,
};
}
function fullName(user: Pick<User, 'details'>): string {
const parts = [user.details?.title, user.details?.firstName, user.details?.lastName].filter(Boolean);
@ -826,7 +287,7 @@ function statusColor(status: UserStatus): string {
}
}
async function loadUsers(): Promise<void> {
async function loadUsers(): Promise<boolean> {
loading.value = true;
try {
const request: ListUsersRequest = {
@ -848,9 +309,11 @@ async function loadUsers(): Promise<void> {
};
} catch (error) {
notifyError(error);
} finally {
loading.value = false;
return false;
}
loading.value = false;
return true;
}
async function onTableRequest(props: { pagination: NonNullable<QTableProps['pagination']> }): Promise<void> {
@ -858,246 +321,49 @@ async function onTableRequest(props: { pagination: NonNullable<QTableProps['pagi
...pagination.value,
...props.pagination,
};
await loadUsers();
const err = await loadUsers();
if (!err) {
$q.notify({
type: 'negative',
message: 'Errore durante il caricamento degli utenti',
});
}
}
function openCreateDialog(): void {
dialogMode.value = 'create';
editorTab.value = 'account';
resetForm();
editorDialogUserId.value = '';
dialogOpen.value = true;
}
async function openEditDialog(uuid: string): Promise<void> {
await openUserDialog('edit', uuid);
function openEditDialog(id: string): void {
dialogMode.value = 'edit';
editorDialogUserId.value = id;
dialogOpen.value = true;
}
async function openViewDialog(uuid: string): Promise<void> {
await openUserDialog('view', uuid);
function openViewDialog(id: string): void {
dialogMode.value = 'view';
editorDialogUserId.value = id;
dialogOpen.value = true;
}
async function openPasswordDialog(uuid: string): Promise<void> {
loading.value = true;
try {
const profile = await resolveUserProfile(uuid);
passwordDialogUserUuid.value = profile.uuid;
passwordDialogUserEmail.value = profile.email;
passwordForm.password = '';
passwordForm.confirmPassword = '';
function openPasswordDialog(id: string): void {
passwordDialogUserId.value = id;
passwordDialogOpen.value = true;
} catch (error) {
notifyError(error);
} finally {
loading.value = false;
}
}
async function openAvatarDialog(uuid: string): Promise<void> {
loading.value = true;
try {
const profile = await resolveUserProfile(uuid);
avatarDialogUser.uuid = profile.uuid;
avatarDialogUser.email = profile.email;
avatarFile.value = null;
avatarSource.value = profile.avatar ?? '';
avatarPreview.value = profile.avatar ?? '';
function openAvatarDialog(id: string): void {
avatarDialogUserId.value = id;
avatarDialogOpen.value = true;
} catch (error) {
notifyError(error);
} finally {
loading.value = false;
}
}
async function openUserDialog(mode: DialogMode, uuid: string): Promise<void> {
loading.value = true;
try {
const profile = await resolveUserProfile(uuid);
dialogMode.value = mode;
editorTab.value = 'account';
resetForm(profile);
dialogOpen.value = true;
} catch (error) {
notifyError(error);
} finally {
loading.value = false;
}
}
async function saveUser(): Promise<void> {
if (dialogMode.value === 'view') {
return;
}
if (!form.name.trim() || !form.email.trim()) {
$q.notify({ type: 'negative', message: 'Nome ed email sono obbligatori.' });
return;
}
if (dialogMode.value === 'create' && form.password.trim().length < 8) {
$q.notify({ type: 'negative', message: 'La password deve contenere almeno 8 caratteri.' });
return;
}
saving.value = true;
try {
if (dialogMode.value === 'create') {
const response = await createUser(payload.value as UserCreateRequest);
if (response.error) {
throw new Error(response.error);
}
$q.notify({ type: 'positive', message: `Utente ${response.data.email} creato.` });
} else if (form.uuid) {
const response = await updateUser(payload.value as UpdateUserRequest);
if (response.error) {
throw new Error(response.error);
}
$q.notify({ type: 'positive', message: `Utente ${response.data.email} aggiornato.` });
}
dialogOpen.value = false;
await loadUsers();
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
async function savePasswordChange(): Promise<void> {
if (passwordForm.password.trim().length < 8) {
$q.notify({ type: 'negative', message: 'La password deve contenere almeno 8 caratteri.' });
return;
}
if (passwordForm.password !== passwordForm.confirmPassword) {
$q.notify({ type: 'negative', message: 'Le password non coincidono.' });
return;
}
saving.value = true;
try {
const profile = await resolveUserProfile(passwordDialogUserUuid.value);
const response = await updateUser(
buildUpdatePayload(profile, passwordForm.password),
);
if (response.error) {
throw new Error(response.error);
}
passwordDialogOpen.value = false;
$q.notify({ type: 'positive', message: `Password aggiornata per ${response.data.email}.` });
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
function openAvatarFilePicker(): void {
avatarFileInputRef.value?.click();
}
function onAvatarFileSelected(event: Event): void {
const target = event.target as HTMLInputElement | null;
const selected = target?.files?.[0] ?? null;
if (!selected) {
return;
}
avatarFile.value = selected;
const reader = new FileReader();
reader.onload = () => {
avatarSource.value = typeof reader.result === 'string' ? reader.result : '';
avatarPreview.value = avatarSource.value;
};
reader.readAsDataURL(selected);
}
function resetAvatarCrop(): void {
avatarCropperRef.value?.cropper?.reset?.();
}
async function saveAvatar(): Promise<void> {
if (!avatarSource.value) {
$q.notify({ type: 'negative', message: 'Seleziona unimmagine prima di salvare.' });
return;
}
saving.value = true;
try {
const profile = await resolveUserProfile(avatarDialogUser.uuid);
const avatarDataUrl =
avatarCropperRef.value?.cropper?.getDataURL?.({
width: 256,
height: 256,
rounded: true,
}) || avatarSource.value;
const response = await updateUser(
{
...buildUpdatePayload(profile),
avatar: avatarDataUrl,
},
);
if (response.error) {
throw new Error(response.error);
}
avatarPreview.value = avatarDataUrl;
avatarDialogOpen.value = false;
$q.notify({ type: 'positive', message: `Avatar aggiornato per ${response.data.email}.` });
await loadUsers();
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
function openBlockDialog(user: User
): void {
blockDialogUser.uuid = user.uuid;
blockDialogUser.email = user.email;
blockDialogUser.status = user.status;
blockDialogBlocked.value = user.status === EnumUserStatus.UserStatusDisabled;
blockDialogUserId.value = user.id;
blockDialogOpen.value = true;
}
async function saveBlockState(): Promise<void> {
saving.value = true;
try {
const payload: BlockUserRequest = {
uuid: blockDialogUser.uuid,
action: blockDialogBlocked.value ? 'block' : 'unblock',
};
const response = await blockUser(payload);
if (response.error) {
throw new Error(response.error);
}
blockDialogOpen.value = false;
$q.notify({
type: 'positive',
message: blockDialogBlocked.value
? `Utente ${response.data.email} bloccato.`
: `Utente ${response.data.email} sbloccato.`,
});
await loadUsers();
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
async function resolveUserProfile(uuid: string): Promise<User> {
const response = await getUser(uuid);
if (response.error) {
throw new Error(response.error);
}
return response.data;
}
function notifyError(error: unknown): void {
$q.notify({
type: 'negative',

View File

@ -0,0 +1,444 @@
<template>
<q-dialog v-model="isOpen">
<q-card class="editor-card modal-card">
<q-card-section class="editor-header">
<div class="editor-toolbar">
<div class="editor-headline">
<div class="text-overline">Avatar editor</div>
<div class="text-h5">{{ avatarState.email || 'User avatar' }}</div>
<div class="text-caption">Ritaglio circolare per il profilo utente</div>
</div>
</div>
<div class="editor-toolbar-actions">
<q-btn flat color="white" label="Chiudi" @click="isOpen = false" />
<q-btn
color="white"
text-color="primary"
unelevated
:disable="!avatarSource"
:loading="saving"
label="Salva avatar"
@click="emitSave"
/>
</div>
</q-card-section>
<q-separator />
<q-card-section class="avatar-editor-body">
<div class="avatar-toolbar">
<input
ref="avatarFileInputRef"
class="visually-hidden"
type="file"
accept="image/*"
@change="onAvatarFileSelected"
/>
<q-btn color="primary" icon="upload" label="Carica immagine" @click="openAvatarFilePicker" />
<div class="avatar-file-name">
{{ avatarFile?.name || 'Nessun file selezionato' }}
</div>
<q-btn
flat
color="primary"
icon="restart_alt"
label="Reset crop"
:disable="!avatarSource"
@click="resetAvatarCrop"
/>
</div>
<div class="avatar-editor-grid">
<div class="avatar-cropper-shell">
<div v-if="avatarSource" class="avatar-cropper-box">
<VuePictureCropper
ref="avatarCropperRef"
:img="avatarSource"
:box-style="avatarCropperBoxStyle"
:options="avatarCropperOptions"
:preset-mode="avatarPresetMode"
/>
</div>
<div v-else class="avatar-empty-state">
Seleziona unimmagine per modificare lavatar.
</div>
</div>
<div class="avatar-preview-shell">
<div class="avatar-preview-title">Anteprima</div>
<div class="avatar-preview-disc">
<img v-if="avatarPreview" :src="avatarPreview" alt="Avatar preview" />
<span v-else>No avatar</span>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import VuePictureCropper from 'vue-picture-cropper';
import { getUser } from 'src/api/users';
import type { AvatarDialogState } from './types';
const props = defineProps<{
modelValue: boolean;
userUuid?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [];
}>();
const $q = useQuasar();
const saving = ref(false);
const avatarState = reactive<AvatarDialogState>({
id: '',
email: '',
avatar: '',
});
const avatarFile = ref<File | null>(null);
const avatarFileInputRef = ref<HTMLInputElement | null>(null);
const avatarSource = ref('');
const avatarPreview = ref('');
const avatarCropperRef = ref<{
cropper?: {
getDataURL?: (options?: { width?: number; height?: number; rounded?: boolean }) => string;
reset?: () => void;
};
} | null>(null);
const avatarCropperBoxStyle = {
width: '100%',
height: '100%',
backgroundColor: '#f3f7fb',
margin: '0 auto',
};
const avatarPresetMode = {
mode: 'round',
width: 320,
height: 320,
} as const;
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
watch(
() => [props.modelValue, props.userUuid] as const,
([open, userUuid]) => {
if (!open || !userUuid) {
return;
}
void prepareDialog(userUuid);
},
{ immediate: true },
);
async function prepareDialog(userUuid: string): Promise<void> {
try {
const response = await getUser(userUuid);
if (response.error) {
throw new Error(response.error);
}
avatarState.id = response.data.id;
avatarState.email = response.data.email;
avatarState.avatar = response.data.avatar ?? '';
avatarFile.value = null;
avatarSource.value = avatarState.avatar;
avatarPreview.value = avatarState.avatar;
} catch (error) {
notifyError(error);
isOpen.value = false;
}
}
function syncAvatarPreview(): void {
avatarPreview.value =
avatarCropperRef.value?.cropper?.getDataURL?.({
width: 220,
height: 220,
rounded: true,
}) || avatarSource.value;
}
const avatarCropperOptions = {
viewMode: 1 as const,
dragMode: 'move' as const,
aspectRatio: 1,
autoCropArea: 0.9,
background: false,
movable: true,
zoomable: true,
scalable: false,
guides: false,
ready: () => {
syncAvatarPreview();
},
crop: () => {
syncAvatarPreview();
},
};
function openAvatarFilePicker(): void {
avatarFileInputRef.value?.click();
}
function onAvatarFileSelected(event: Event): void {
const target = event.target as HTMLInputElement | null;
const selected = target?.files?.[0] ?? null;
if (!selected) {
return;
}
avatarFile.value = selected;
const reader = new FileReader();
reader.onload = () => {
avatarSource.value = typeof reader.result === 'string' ? reader.result : '';
avatarPreview.value = avatarSource.value;
};
reader.readAsDataURL(selected);
}
function resetAvatarCrop(): void {
avatarCropperRef.value?.cropper?.reset?.();
}
function emitSave(): void {
const avatarDataUrl =
avatarCropperRef.value?.cropper?.getDataURL?.({
width: 256,
height: 256,
rounded: true,
}) || avatarSource.value;
void saveAvatar(avatarDataUrl);
}
function saveAvatar(avatarDataUrl: string): void {
if (!avatarDataUrl) {
$q.notify({ type: 'negative', message: 'Seleziona unimmagine prima di salvare.' });
return;
}
saving.value = true;
try {
// TODO: manca un endpoint backend/frontend dedicato per aggiornare l'avatar utente.
$q.notify({
type: 'warning',
message: 'Endpoint per il salvataggio avatar non ancora disponibile.',
});
} finally {
saving.value = false;
}
}
function notifyError(error: unknown): void {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : String(error),
});
}
</script>
<style scoped>
.editor-card {
border-radius: 24px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px);
}
.modal-card {
width: min(96vw, 800px);
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.editor-header {
background: linear-gradient(135deg, #0d47a1, #00897b);
color: white;
position: sticky;
top: 0;
z-index: 5;
padding-top: 20px;
padding-right: 24px;
padding-left: 24px;
}
.editor-toolbar {
display: block;
margin-bottom: 16px;
padding-right: 260px;
}
.editor-headline {
min-width: 0;
}
.editor-toolbar-actions {
position: absolute;
top: 20px;
right: 24px;
display: inline-flex;
align-items: flex-start;
gap: 10px;
z-index: 2;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.avatar-editor-body {
padding: 24px;
overflow: auto;
}
.avatar-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.avatar-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) 280px;
gap: 24px;
align-items: start;
}
.avatar-file-name {
min-width: 0;
flex: 1 1 auto;
color: #617487;
font-size: 0.92rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.avatar-cropper-shell,
.avatar-preview-shell {
border-radius: 24px;
background: linear-gradient(180deg, #f7fafd 0%, #edf3f9 100%);
border: 1px solid rgba(13, 71, 161, 0.08);
}
.avatar-cropper-shell {
min-height: 520px;
padding: 18px;
}
.avatar-cropper-box {
width: 100%;
height: 480px;
overflow: hidden;
border-radius: 20px;
}
.avatar-empty-state {
min-height: 480px;
display: flex;
align-items: center;
justify-content: center;
color: #617487;
text-align: center;
padding: 24px;
}
.avatar-preview-shell {
padding: 20px;
position: sticky;
top: 112px;
}
.avatar-preview-title {
margin-bottom: 14px;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #0d47a1;
}
.avatar-preview-disc {
width: 220px;
height: 220px;
margin: 0 auto;
border-radius: 999px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 30% 30%, #ffffff 0%, #dce6f1 100%);
color: #617487;
text-align: center;
}
.avatar-preview-disc img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 900px) {
.editor-toolbar {
padding-right: 0;
}
.editor-toolbar-actions {
position: static;
margin-bottom: 12px;
}
.avatar-toolbar {
flex-direction: column;
align-items: stretch;
}
.avatar-editor-grid {
grid-template-columns: 1fr;
}
.avatar-cropper-shell {
min-height: 420px;
}
.avatar-cropper-box,
.avatar-empty-state {
height: 380px;
min-height: 380px;
}
.avatar-preview-shell {
position: static;
}
}
</style>

View File

@ -0,0 +1,161 @@
<template>
<q-dialog v-model="isOpen">
<q-card class="password-card modal-card">
<q-card-section>
<div class="text-overline text-primary">User access</div>
<div class="text-h6">{{ blockState.email || 'User' }}</div>
<div class="text-caption">
Stato attuale: {{ blockState.status || 'n/a' }}
</div>
</q-card-section>
<q-separator />
<q-card-section class="password-grid">
<q-toggle
v-model="blockedModel"
checked-icon="block"
unchecked-icon="lock_open"
color="negative"
:label="blockState.blocked ? 'Utente bloccato' : 'Utente attivo'"
/>
<div class="text-body2 text-grey-7">
{{
blockState.blocked
? 'Lutente non potra piu accedere finche non verra sbloccato.'
: 'Lutente potra accedere normalmente.'
}}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="grey-7" label="Chiudi" @click="isOpen = false" />
<q-btn
color="primary"
:loading="saving"
:label="blockState.blocked ? 'Salva blocco' : 'Salva sblocco'"
@click="saveBlockState"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { blockUser } from 'src/api/admin';
import { EnumUserStatus, getUser } from 'src/api/users';
import type { BlockDialogState } from './types';
const props = defineProps<{
modelValue: boolean;
userUuid?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [];
}>();
const $q = useQuasar();
const saving = ref(false);
const blockState = reactive<BlockDialogState>({
id: '',
email: '',
status: 'pending',
blocked: false,
});
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const blockedModel = computed({
get: () => blockState.blocked,
set: (value: boolean) => {
blockState.blocked = value;
},
});
watch(
() => [props.modelValue, props.userUuid] as const,
([open, userUuid]) => {
if (!open || !userUuid) {
return;
}
void prepareDialog(userUuid);
},
{ immediate: true },
);
async function prepareDialog(userUuid: string): Promise<void> {
try {
const response = await getUser(userUuid);
if (response.error) {
throw new Error(response.error);
}
blockState.id = response.data.id;
blockState.email = response.data.email;
blockState.status = response.data.status;
blockState.blocked = response.data.status === EnumUserStatus.UserStatusDisabled;
} catch (error) {
notifyError(error);
isOpen.value = false;
}
}
async function saveBlockState(): Promise<void> {
saving.value = true;
try {
const response = await blockUser({
id: blockState.id,
action: blockState.blocked ? 'block' : 'unblock',
});
if (response.error) {
throw new Error(response.error);
}
isOpen.value = false;
$q.notify({
type: 'positive',
message: blockState.blocked
? `Utente ${response.data.email} bloccato.`
: `Utente ${response.data.email} sbloccato.`,
});
emit('saved');
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
function notifyError(error: unknown): void {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : String(error),
});
}
</script>
<style scoped>
.modal-card {
width: min(96vw, 460px);
max-width: 460px;
}
.password-card {
border-radius: 20px;
}
.password-grid {
display: grid;
gap: 14px;
}
</style>

View File

@ -0,0 +1,636 @@
<template>
<q-dialog v-model="isOpen">
<q-card class="editor-card modal-card">
<q-form class="form-grid" @submit.prevent="saveUser">
<q-card-section class="editor-header">
<div class="editor-toolbar">
<div class="editor-headline">
<div class="text-overline">
{{
dialogMode === 'create'
? 'Nuovo utente'
: dialogMode === 'edit'
? 'Modifica utente'
: 'Dettaglio utente'
}}
</div>
<div class="text-h5">{{ formModel.name || 'Profilo utente' }}</div>
<div class="text-caption">{{ formModel.email || 'Compila i dati di base' }}</div>
</div>
</div>
<div class="editor-toolbar-actions">
<q-btn flat color="white" label="Chiudi" @click="isOpen = false" />
<q-btn
v-if="dialogMode !== 'view'"
color="white"
text-color="primary"
unelevated
:loading="saving"
:label="dialogMode === 'create' ? 'Crea utente' : 'Salva modifiche'"
type="submit"
/>
</div>
<q-tabs
v-model="tabModel"
dense
align="left"
inline-label
active-color="primary"
indicator-color="primary"
class="editor-tabs"
>
<q-tab name="account" icon="person" label="Account" />
<q-tab name="details" icon="badge" label="Details" />
<q-tab name="preferences" icon="tune" label="Preferences" />
</q-tabs>
</q-card-section>
<q-card-section class="editor-body">
<q-tab-panels v-model="tabModel" animated class="editor-panels">
<q-tab-panel name="account">
<section class="form-section">
<h2>Account</h2>
<div class="section-grid">
<q-input v-model="formModel.name" outlined label="Nome" :readonly="dialogMode === 'view'" />
<q-input v-model="formModel.email" outlined label="Email" type="email" :readonly="dialogMode === 'view'" />
<q-input
v-if="dialogMode === 'create'"
v-model="formModel.password"
outlined
label="Password"
type="password"
hint="Minimo 8 caratteri"
/>
<div v-if="dialogMode !== 'create'" class="avatar-inline-card span-2">
<div class="avatar-inline-preview">
<img v-if="formModel.avatar" :src="formModel.avatar" :alt="formModel.name" />
<span v-else>{{ avatarInitials(formModel) }}</span>
</div>
<div class="avatar-inline-meta">
<div class="text-subtitle2">Avatar</div>
<div class="text-caption text-grey-7">
{{ formModel.avatar ? 'Avatar profilo impostato' : 'Nessun avatar impostato' }}
</div>
</div>
<q-btn
v-if="dialogMode === 'edit'"
flat
color="primary"
icon="add_a_photo"
label="Modifica"
@click="emit('open-avatar', formModel.id)"
/>
</div>
<q-input v-else v-model="formModel.avatar" outlined label="Avatar URL" />
<q-select
v-model="formModel.status"
outlined
label="Status"
:options="statusOptions"
:readonly="dialogMode === 'view'"
/>
<q-select
v-model="formModel.permission"
outlined
label="Roles"
:options="rolesOptions"
:readonly="dialogMode === 'view'"
/>
<q-select
v-model="formModel.type"
outlined
label="Types"
:options="typeOptions"
:readonly="dialogMode === 'view'"
/>
</div>
</section>
</q-tab-panel>
<q-tab-panel name="details">
<section class="form-section">
<div class="section-heading">
<h2>Details</h2>
<q-toggle
v-model="detailsEnabledModel"
label="Abilita details"
:disable="dialogMode === 'view'"
/>
</div>
<div class="section-grid" :class="{ readonlyd: !detailsEnabled }">
<q-input v-model="formModel.details.title" outlined label="Title" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.firstName" outlined label="First name" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.lastName" outlined label="Last name" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.phone" outlined label="Phone" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.address" outlined label="Address" class="span-2" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.city" outlined label="City" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.zipCode" outlined label="Zip code" :readonly="!detailsEnabled || dialogMode === 'view'" />
<q-input v-model="formModel.details.country" outlined label="Country" :readonly="!detailsEnabled || dialogMode === 'view'" />
</div>
</section>
</q-tab-panel>
<q-tab-panel name="preferences">
<section class="form-section">
<div class="section-heading">
<h2>Preferences</h2>
<q-toggle
v-model="preferencesEnabledModel"
label="Abilita preferences"
:disable="dialogMode === 'view'"
/>
</div>
<div class="section-grid" :class="{ readonlyd: !preferencesEnabled }">
<q-select
v-model="formModel.preferences.language"
emit-value
outlined
label="Language"
:options="languageOptions"
:readonly="!preferencesEnabled || dialogMode === 'view'"
/>
<q-input
v-model.number="formModel.preferences.idleTimeout"
outlined
type="number"
label="Idle timeout"
:readonly="!preferencesEnabled || dialogMode === 'view'"
/>
<q-input v-model="formModel.preferences.idlePin" outlined label="Idle pin" :readonly="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="formModel.preferences.useIdle" label="Use idle" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="formModel.preferences.useIdlePassword" label="Use idle password" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="formModel.preferences.useDirectLogin" label="Use direct login" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="formModel.preferences.useQuadcodeLogin" label="Use quadcode login" :disable="!preferencesEnabled || dialogMode === 'view'" />
<q-toggle v-model="formModel.preferences.sendNoticesMail" label="Send notices mail" :disable="!preferencesEnabled || dialogMode === 'view'" />
</div>
</section>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-form>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { EnumUserRole } from 'src/api/auth';
import { updateUser } from 'src/api/admin';
import { EnumUserStatus, EnumUserTypes, createUser, getUser, type UpdateUserRequest, type UserCreateRequest, type UserDetails, type UserPreferences, type UpdateUserDetailsRequest , type UpdateUserPreferencesRequest } from 'src/api/users';
import { adminUpdateUserPreferences, adminUpdateUserDetails } from 'src/api/admin';
import { createEmptyUserForm, mapUserToForm, type DialogMode, type EditorTab, type UserDetailsForm, type UserFormState, type UserPreferencesForm } from './types';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const langs = [
{
code: 'it',
short_name: 'IT',
},
{
code: 'en',
short_name: 'EN',
},
{
code: 'en_us',
short_name: 'EN',
},
{
code: 'de',
short_name: 'DE',
},
{
code: 'de_ch',
short_name: 'DE',
},
{
code: 'fr',
short_name: 'FR',
},
{
code: 'fr_ch',
short_name: 'FR',
},
];
const languageOptions = computed(() =>
langs.map((lang) => ({
label: t(`language.${lang.code}`),
value: t(`language.${lang.code}`),
})),
);
const props = defineProps<{
modelValue: boolean;
dialogMode: DialogMode;
userId?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [];
'open-avatar': [id: string];
}>();
const $q = useQuasar();
const saving = ref(false);
const loading = ref(false);
const editorTab = ref<EditorTab>('account');
const detailsEnabled = ref(true);
const preferencesEnabled = ref(true);
const statusOptions = Object.values(EnumUserStatus);
const rolesOptions = Object.values(EnumUserRole);
const typeOptions = Object.values(EnumUserTypes);
const formModel = reactive<UserFormState>(createEmptyUserForm());
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const tabModel = computed({
get: () => editorTab.value,
set: (value: EditorTab) => {
editorTab.value = value;
},
});
const detailsEnabledModel = computed({
get: () => detailsEnabled.value,
set: (value: boolean) => {
detailsEnabled.value = value;
},
});
const preferencesEnabledModel = computed({
get: () => preferencesEnabled.value,
set: (value: boolean) => {
preferencesEnabled.value = value;
},
});
watch(
() => [props.modelValue, props.userId, props.dialogMode] as const,
([open, userId, dialogMode]) => {
if (!open) {
return;
}
void prepareDialog(dialogMode, userId);
},
{ immediate: true },
);
async function prepareDialog(dialogMode: DialogMode, userId?: string): Promise<void> {
debugger
loading.value = true;
editorTab.value = 'account';
try {
if (dialogMode === 'create') {
resetForm();
return;
}
if (!userId) {
throw new Error('ID utente mancante.');
}
const response = await getUser(userId);
if (response.error) {
throw new Error(response.error);
}
resetForm(response.data);
} catch (error) {
notifyError(error);
isOpen.value = false;
} finally {
loading.value = false;
}
}
function resetForm(user?: Parameters<typeof mapUserToForm>[0]): void {
const next = user ? mapUserToForm(user) : createEmptyUserForm();
Object.assign(formModel, next);
detailsEnabled.value = !!user?.details || !user;
preferencesEnabled.value = !!user?.preferences || !user;
}
function normalizeNullableString(value: string): string | null {
const normalized = value.trim();
return normalized === '' ? null : normalized;
}
function sanitizeDetails(value: UserDetailsForm): UserDetails | null {
const normalized: UserDetailsForm = {
id: value.id,
userId: value.userId,
title: value.title.trim(),
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
address: value.address.trim(),
city: value.city.trim(),
zipCode: value.zipCode.trim(),
country: value.country.trim(),
phone: value.phone.trim(),
};
return Object.values(normalized).some(Boolean) ? normalized as UserDetails : null;
}
function sanitizePreferences(value: UserPreferencesForm): UserPreferences | null {
const normalized: UserPreferencesForm = {
id: value.id,
userId: value.userId,
useIdle: value.useIdle,
idleTimeout: Number(value.idleTimeout) || 0,
useIdlePassword: value.useIdlePassword,
idlePin: value.idlePin.trim(),
useDirectLogin: value.useDirectLogin,
useQuadcodeLogin: value.useQuadcodeLogin,
sendNoticesMail: value.sendNoticesMail,
language: value.language,
};
const hasData =
normalized.useIdle ||
normalized.idleTimeout > 0 ||
normalized.useIdlePassword ||
normalized.idlePin !== '' ||
normalized.useDirectLogin ||
normalized.useQuadcodeLogin ||
normalized.sendNoticesMail ||
normalized.language !== '';
return hasData ? normalized as UserPreferences : null;
}
async function saveUser(): Promise<void> {
if (props.dialogMode === 'view') {
return;
}
if (!formModel.name.trim() || !formModel.email.trim()) {
$q.notify({ type: 'negative', message: 'Nome ed email sono obbligatori.' });
return;
}
if (props.dialogMode === 'create' && formModel.password.trim().length < 8) {
$q.notify({ type: 'negative', message: 'La password deve contenere almeno 8 caratteri.' });
return;
}
const payload = {
id: formModel.id,
name: formModel.name.trim(),
email: formModel.email.trim(),
password: props.dialogMode === 'create' ? formModel.password : '',
status: formModel.status,
permission: formModel.permission,
type: formModel.type,
avatar: normalizeNullableString(formModel.avatar),
details: detailsEnabled.value ? sanitizeDetails(formModel.details) : null,
preferences: preferencesEnabled.value ? sanitizePreferences(formModel.preferences) : null,
};
saving.value = true;
try {
if (props.dialogMode === 'create') {
const response = await createUser(payload as UserCreateRequest);
if (response.error) {
throw new Error(response.error);
}
$q.notify({ type: 'positive', message: `Utente ${response.data.email} creato.` });
} else {
console.log('Updating user with payload:', editorTab.value, payload);
let response;
switch (editorTab.value) {
case 'account':
response = await updateUser(payload as UpdateUserRequest);
if (response.error) {
throw new Error(response.error);
}
break;
case 'details':
response = await adminUpdateUserDetails(payload.details as unknown as UpdateUserDetailsRequest);
if (response.error) {
throw new Error(response.error);
}
break;
case 'preferences':
response = await adminUpdateUserPreferences(payload.preferences as unknown as UpdateUserPreferencesRequest);
if (response.error) {
throw new Error(response.error);
}
break;
}
if (!response) {
response = await updateUser(payload as UpdateUserRequest);
}
if (response.error) {
throw new Error(response.error);
}
$q.notify({ type: 'positive', message: `Utente ${response.data.email} aggiornato.` });
}
isOpen.value = false;
emit('saved');
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
function avatarInitials(user: Pick<UserFormState, 'name' | 'email'>): string {
const source = user.name.trim() || user.email.trim();
const parts = source.split(/\s+/).filter(Boolean);
const first = parts[0] ?? '';
const second = parts[1] ?? '';
if (parts.length === 0) {
return '?';
}
if (parts.length === 1) {
return first.slice(0, 2).toUpperCase();
}
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase();
}
function notifyError(error: unknown): void {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : String(error),
});
}
</script>
<style>
.readonly, .readonly *, [readonly], [readonly] * {
outline: 0 !important;
cursor: default !important;
}
.disabled, .disabled *, [disabled], [disabled] * {
outline: 0 !important;
cursor: default !important;
}
</style>
<style scoped>
.editor-card {
border-radius: 24px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(14px);
}
.modal-card {
width: min(96vw, 800px);
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.form-grid {
display: grid;
gap: 24px;
}
.editor-header {
background: linear-gradient(135deg, #0d47a1, #00897b);
color: white;
position: sticky;
top: 0;
z-index: 5;
padding-top: 20px;
padding-right: 24px;
padding-left: 24px;
}
.editor-toolbar {
display: block;
margin-bottom: 16px;
padding-right: 260px;
}
.editor-headline {
min-width: 0;
}
.editor-toolbar-actions {
position: absolute;
top: 20px;
right: 24px;
display: inline-flex;
align-items: flex-start;
gap: 10px;
z-index: 2;
}
.editor-body {
padding: 24px;
padding-top: 0;
overflow: auto;
}
.editor-tabs {
border-radius: 16px;
background: rgba(13, 71, 161, 0.06);
padding: 6px;
}
.editor-panels {
background: transparent;
}
.form-section {
display: grid;
gap: 14px;
}
.form-section h2 {
margin: 0;
font-size: 1.1rem;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.section-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.avatar-inline-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(13, 71, 161, 0.12);
background: linear-gradient(180deg, #f8fbff 0%, #eef4fa 100%);
}
.avatar-inline-preview {
width: 72px;
height: 72px;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0d47a1, #26a69a);
color: white;
font-size: 1rem;
font-weight: 700;
}
.avatar-inline-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-inline-meta {
min-width: 0;
flex: 1 1 auto;
}
.span-2 {
grid-column: span 2;
}
@media (max-width: 900px) {
.section-grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: auto;
}
.editor-toolbar {
padding-right: 0;
}
.editor-toolbar-actions {
position: static;
margin-bottom: 12px;
}
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<q-dialog v-model="isOpen">
<q-card class="password-card modal-card">
<q-card-section>
<div class="text-overline text-primary">Change password</div>
<div class="text-h6">{{ userEmail || 'User' }}</div>
</q-card-section>
<q-separator />
<q-card-section class="password-grid">
<q-input
v-model="formModel.password"
outlined
type="password"
label="New password"
hint="Minimo 8 caratteri"
/>
<q-input
v-model="formModel.confirmPassword"
outlined
type="password"
label="Confirm password"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="grey-7" label="Chiudi" @click="isOpen = false" />
<q-btn color="primary" label="Salva password" :loading="saving" @click="savePasswordChange" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { getUser, updatePassword } from 'src/api/users';
import type { PasswordFormState } from './types';
const props = defineProps<{
modelValue: boolean;
userId?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [];
}>();
const $q = useQuasar();
const saving = ref(false);
const userEmail = ref('');
const formModel = reactive<PasswordFormState>({
password: '',
confirmPassword: '',
});
const isOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
watch(
() => [props.modelValue, props.userId] as const,
([open, userId]) => {
if (!open || !userId) {
return;
}
void prepareDialog(userId);
},
{ immediate: true },
);
async function prepareDialog(userId: string): Promise<void> {
try {
const response = await getUser(userId);
if (response.error) {
throw new Error(response.error);
}
userEmail.value = response.data.email;
formModel.password = '';
formModel.confirmPassword = '';
} catch (error) {
notifyError(error);
isOpen.value = false;
}
}
async function savePasswordChange(): Promise<void> {
if (!props.userId) {
$q.notify({ type: 'negative', message: 'ID utente mancante.' });
return;
}
if (formModel.password.trim().length < 8) {
$q.notify({ type: 'negative', message: 'La password deve contenere almeno 8 caratteri.' });
return;
}
if (formModel.password !== formModel.confirmPassword) {
$q.notify({ type: 'negative', message: 'Le password non coincidono.' });
return;
}
saving.value = true;
try {
const response = await updatePassword({
id: props.userId,
password: formModel.password,
});
if (response.error) {
throw new Error(response.error);
}
isOpen.value = false;
$q.notify({ type: 'positive', message: `Password aggiornata per ${userEmail.value || 'utente'}.` });
emit('saved');
} catch (error) {
notifyError(error);
} finally {
saving.value = false;
}
}
function notifyError(error: unknown): void {
$q.notify({
type: 'negative',
message: error instanceof Error ? error.message : String(error),
});
}
</script>
<style scoped>
.modal-card {
width: min(96vw, 460px);
max-width: 460px;
}
.password-card {
border-radius: 20px;
}
.password-grid {
display: grid;
gap: 14px;
}
</style>

View File

@ -0,0 +1,114 @@
import type { User, UserDetails, UserPreferences, UserStatus } from 'src/api/users';
export type DialogMode = 'create' | 'edit' | 'view';
export type EditorTab = 'account' | 'details' | 'preferences';
export type UserDetailsForm = Omit<UserDetails, 'createdAt' | 'updatedAt'>;
export type UserPreferencesForm = Omit<UserPreferences, 'createdAt' | 'updatedAt'>;
export interface UserFormState {
id: string;
name: string;
email: string;
password: string;
status: UserStatus;
permission: string;
type: string;
avatar: string;
details: UserDetailsForm;
preferences: UserPreferencesForm;
}
export interface PasswordFormState {
password: string;
confirmPassword: string;
}
export interface BlockDialogState {
id: string;
email: string;
status: UserStatus;
blocked: boolean;
}
export interface AvatarDialogState {
id: string;
email: string;
avatar: string;
}
export function createEmptyUserForm(): UserFormState {
return {
id: '',
name: '',
email: '',
password: '',
status: 'pending',
permission: 'user',
type: 'internal',
avatar: '',
details: {
id: 0,
userId: '',
title: '',
firstName: '',
lastName: '',
address: '',
city: '',
zipCode: '',
country: '',
phone: '',
},
preferences: {
id: 0,
userId: '',
useIdle: false,
idleTimeout: 0,
useIdlePassword: false,
idlePin: '',
useDirectLogin: false,
useQuadcodeLogin: false,
sendNoticesMail: false,
language: 'it',
},
};
}
export function mapUserToForm(user: User): UserFormState {
return {
id: user.id,
name: user.name,
email: user.email,
password: '',
status: user.status,
permission: user.permission,
type: user.type,
avatar: user.avatar ?? '',
details: {
id: user.details?.id ?? 0,
userId: user.details?.userId ?? '',
title: user.details?.title ?? '',
firstName: user.details?.firstName ?? '',
lastName: user.details?.lastName ?? '',
address: user.details?.address ?? '',
city: user.details?.city ?? '',
zipCode: user.details?.zipCode ?? '',
country: user.details?.country ?? '',
phone: user.details?.phone ?? '',
},
preferences: {
id: user.preferences?.id ?? 0,
userId: user.preferences?.userId ?? '',
useIdle: user.preferences?.useIdle ?? false,
idleTimeout: user.preferences?.idleTimeout ?? 0,
useIdlePassword: user.preferences?.useIdlePassword ?? false,
idlePin: user.preferences?.idlePin ?? '',
useDirectLogin: user.preferences?.useDirectLogin ?? false,
useQuadcodeLogin: user.preferences?.useQuadcodeLogin ?? false,
sendNoticesMail: user.preferences?.sendNoticesMail ?? false,
language: user.preferences?.language ?? 'it',
},
};
}

View File

@ -58,7 +58,7 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useQuasar } from 'quasar';
import { EnumUserStatus, register, type UserCreateRequest } from 'src/api/users';
import { EnumUserStatus, EnumUserTypes, register, type UserCreateRequest } from 'src/api/users';
const $q = useQuasar();
const loading = ref(false);
@ -113,9 +113,11 @@ async function submit(): Promise<void> {
password: form.password,
permission: "user",
status: EnumUserStatus.UserStatusPending,
types: ['internal'],
type: EnumUserTypes.UserTypeInternal,
avatar: null,
details: {
id: 0,
userId: '',
title: '',
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
@ -124,10 +126,6 @@ async function submit(): Promise<void> {
zipCode: '',
country: '',
phone: '',
id: 0,
userId: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
preferences: null,
};

View File

@ -0,0 +1,44 @@
import { defineStore, acceptHMRUpdate } from 'pinia';
import { me, type User } from 'src/api/users';;
import { type Nullable } from 'src/api/apiTypes';
export const useUserStore = defineStore('user', {
state: () => ({
user: null as Nullable<User>,
error: null as Nullable<string>,
counter: 0,
}),
getters: {
userData: (state) => state.user,
userError: (state) => state.error,
userCounter: (state) => state.counter,
},
actions: {
getUser: async function () {
try {
const response = await me();
console.log('Current user:', response);
this.setUser(response.data);
this.error = null;
this.counter++;
} catch (error) {
this.clearUser();
this.counter++;
this.error = 'Failed to fetch user data from server: ' + (error instanceof Error ? error.message : String(error));
}
},
setUser(u: User) {
this.user = u;
},
clearUser() {
this.user = null;
this.error = null;
},
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot));
}