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.
This commit is contained in:
parent
0a7cc993d4
commit
c8784320cf
Binary file not shown.
|
|
@ -115,3 +115,65 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
|
||||||
|
|
||||||
return c.JSON(responses.Success(u))
|
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"server/internal/auth"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -8,9 +10,27 @@ func RegisterAdminRoutes(app fiber.Router) {
|
||||||
adminController := NewAdminController()
|
adminController := NewAdminController()
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=admin.ListUsersResponse
|
// 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
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"server/internal/responses"
|
"server/internal/tokens"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
/* const (
|
|
||||||
"superadmin" = SuperAdminPermission
|
|
||||||
"admin" = AdminPermission
|
|
||||||
"manager" = ManagerPermission
|
|
||||||
"content_creator" = ContentCreatorPermission
|
|
||||||
"user" = UserPermission
|
|
||||||
"guest" = GuestPermission
|
|
||||||
) */
|
|
||||||
|
|
||||||
type Role struct {
|
type Role struct {
|
||||||
Name Permission `json:"name"`
|
Name string `json:"name"`
|
||||||
Permission int `json:"permission"`
|
Permission uint `json:"permission"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var Roles = []Role{
|
var Roles = []Role{
|
||||||
|
|
@ -29,53 +20,29 @@ var Roles = []Role{
|
||||||
{"guest", GuestPermission},
|
{"guest", GuestPermission},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typescript: type
|
// RolesData represents permissions of a user.
|
||||||
type Permission string
|
type RolesData string
|
||||||
|
|
||||||
// Typescript: enum=EnumPermission
|
// Typescript: enum=UserRole
|
||||||
const (
|
const (
|
||||||
RoleSuperAdmin Permission = "superadmin"
|
SuperAdminRole RolesData = "superadmin"
|
||||||
RoleAdmin Permission = "admin"
|
AdminRole RolesData = "admin"
|
||||||
RoleManager Permission = "manager"
|
ManagerRole RolesData = "manager"
|
||||||
RoleContentCreator Permission = "content_creator"
|
ContentCreatorRole RolesData = "content_creator"
|
||||||
RoleUser Permission = "user"
|
UserRole RolesData = "user"
|
||||||
RoleGuest Permission = "guest"
|
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 (
|
const (
|
||||||
SuperAdminPermission = 0b1111111111111111
|
SuperAdminPermission uint = 0b1111111111111111
|
||||||
AdminPermission = 0b0111111111111111
|
AdminPermission uint = 0b0111111111111111
|
||||||
ManagerPermission = 0b0010111111111111
|
ManagerPermission uint = 0b0010111111111111
|
||||||
ContentCreatorPermission = 0b0001111111111111
|
ContentCreatorPermission uint = 0b0001111111111111
|
||||||
UserPermission = 0b0000000000000011
|
UserPermission uint = 0b0000000000000011
|
||||||
GuestPermission = 0b0000000000000001
|
GuestPermission uint = 0b0000000000000001
|
||||||
)
|
)
|
||||||
|
|
||||||
// Typescript: type
|
func PermissionToString(p uint) string {
|
||||||
type AllRoles map[string]string
|
|
||||||
|
|
||||||
func GetRoles(c fiber.Ctx) error {
|
|
||||||
a := make(AllRoles)
|
|
||||||
a["RoleSuperAdmin"] = "superadmin"
|
|
||||||
a["RoleAdmin"] = "admin"
|
|
||||||
a["RoleManager"] = "manager"
|
|
||||||
a["RoleContentCreator"] = "content_creator"
|
|
||||||
a["RoleUser"] = "user"
|
|
||||||
a["RoleGuest"] = "guest"
|
|
||||||
|
|
||||||
return c.JSON(responses.Success(a))
|
|
||||||
}
|
|
||||||
|
|
||||||
func PermissionToString(p int) Permission {
|
|
||||||
for _, role := range Roles {
|
for _, role := range Roles {
|
||||||
if role.Permission == p {
|
if role.Permission == p {
|
||||||
return role.Name
|
return role.Name
|
||||||
|
|
@ -84,7 +51,7 @@ func PermissionToString(p int) Permission {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
func RoleToPermission(s Permission) int {
|
func RoleToPermission(s string) uint {
|
||||||
for _, role := range Roles {
|
for _, role := range Roles {
|
||||||
if role.Name == s {
|
if role.Name == s {
|
||||||
return role.Permission
|
return role.Permission
|
||||||
|
|
@ -92,3 +59,20 @@ func RoleToPermission(s Permission) int {
|
||||||
}
|
}
|
||||||
return 0
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,5 @@ package auth
|
||||||
import "github.com/gofiber/fiber/v3"
|
import "github.com/gofiber/fiber/v3"
|
||||||
|
|
||||||
func RegisterAuthRoutes(app fiber.Router) {
|
func RegisterAuthRoutes(app fiber.Router) {
|
||||||
// Typescript: TSEndpoint= path=/api/roles; name=getRoles; method=GET; response=auth.AllRoles
|
|
||||||
app.Get("/roles", GetRoles)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ func GetAuthClaims(dbConn *gorm.DB, tokenService *tokens.TockenService) fiber.Ha
|
||||||
func AuthMe(c fiber.Ctx) error {
|
func AuthMe(c fiber.Ctx) error {
|
||||||
claims := c.Locals("authClaims")
|
claims := c.Locals("authClaims")
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
|
||||||
creds := make([]Credential, 0, n)
|
creds := make([]Credential, 0, n)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
uuid := gofakeit.UUID()
|
//id := gofakeit.UUID()
|
||||||
email := gofakeit.Email()
|
email := gofakeit.Email()
|
||||||
|
|
||||||
pw, err := randomPassword()
|
pw, err := randomPassword()
|
||||||
|
|
@ -51,8 +51,7 @@ func SeedUsers(db *gorm.DB, n int) ([]users.User, []Credential, error) {
|
||||||
Password: passwordHash,
|
Password: passwordHash,
|
||||||
Permission: auth.PermissionToString(auth.SuperAdminPermission),
|
Permission: auth.PermissionToString(auth.SuperAdminPermission),
|
||||||
Status: users.UserStatusActive,
|
Status: users.UserStatusActive,
|
||||||
Types: users.UserTypes{"internal"},
|
Type: users.UserType("internal"),
|
||||||
UUID: uuid,
|
|
||||||
Details: &users.UserDetails{
|
Details: &users.UserDetails{
|
||||||
Title: gofakeit.JobTitle(),
|
Title: gofakeit.JobTitle(),
|
||||||
FirstName: gofakeit.FirstName(),
|
FirstName: gofakeit.FirstName(),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"server/internal/auth"
|
|
||||||
"server/internal/config"
|
"server/internal/config"
|
||||||
|
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -23,9 +22,10 @@ type TockenService struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
Username string `json:"username"`
|
ID string `json:"id"`
|
||||||
Permission int `json:"permission"`
|
Impersonator string `json:"impersonator,omitempty"`
|
||||||
TokenType string `json:"type"`
|
Permission uint `json:"permission"`
|
||||||
|
TokenType string `json:"type"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,13 +84,13 @@ func HashToken(token string) string {
|
||||||
return hashToken(token)
|
return hashToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TockenService) GenerateTokenPair(user string, permission auth.Permission) (TokenPair, error) {
|
func (s *TockenService) GenerateTokenPair(id string, permission uint) (TokenPair, error) {
|
||||||
access, err := s.GenerateToken(user, permission, TokenTypeAccess, s.accessExpiry)
|
access, err := s.GenerateToken(id, permission, TokenTypeAccess, s.accessExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh, err := s.GenerateToken(user, permission, TokenTypeRefresh, s.refreshExpiry)
|
refresh, err := s.GenerateToken(id, permission, TokenTypeRefresh, s.refreshExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +117,7 @@ func (s *TockenService) Refresh(refreshToken string) (TokenPair, error) {
|
||||||
if claims.TokenType != TokenTypeRefresh {
|
if claims.TokenType != TokenTypeRefresh {
|
||||||
return TokenPair{}, errors.New("refresh token required")
|
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) {
|
func (s *TockenService) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
|
@ -158,13 +158,13 @@ func (s *TockenService) ParseToken(tokenString string) (*Claims, error) {
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TockenService) GenerateToken(user string, permission auth.Permission, tokenType string, expiry time.Duration) (string, error) {
|
func (s *TockenService) GenerateToken(id string, permission uint, tokenType string, expiry time.Duration) (string, error) {
|
||||||
p := auth.RoleToPermission(permission)
|
|
||||||
|
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
Username: user,
|
ID: id,
|
||||||
Permission: p,
|
Impersonator: "",
|
||||||
TokenType: tokenType,
|
Permission: permission,
|
||||||
|
TokenType: tokenType,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: s.cfg.Issuer,
|
Issuer: s.cfg.Issuer,
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 {
|
func (uc *UserController) GetUser(c fiber.Ctx) error {
|
||||||
user, err := loadUserByUUID(c, c.Params("uuid"))
|
user, err := GetUserByID(c.Params("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.
|
// CreateUser creates a user together with optional details and preferences.
|
||||||
|
|
@ -52,58 +51,12 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := db.DBFromCtx(c)
|
user, err := CreateUser(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing User
|
return c.Status(fiber.StatusCreated).JSON(responses.Success(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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser replaces user fields and synchronizes details/preferences.
|
// UpdateUser replaces user fields and synchronizes details/preferences.
|
||||||
|
|
@ -116,84 +69,42 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := db.DBFromCtx(c)
|
user, err := UpdateUser(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Email != user.Email {
|
user, err := GetUserByID(req.UserID)
|
||||||
var existing User
|
if err != nil {
|
||||||
if err := db.Select("id").Where("email = ?", req.Email).First(&existing).Error; err == nil && existing.ID != user.ID {
|
return fiber.NewError(fiber.StatusBadRequest, "invalid 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
return c.JSON(responses.Success(user))
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
|
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
|
||||||
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
|
||||||
db, err := db.DBFromCtx(c)
|
if err := DeleteUser(c.Params("uuid")); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
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"}))
|
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")
|
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 {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
|
||||||
}
|
}
|
||||||
|
|
@ -300,14 +211,14 @@ func (uc *UserController) Register(c fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
return req.Status
|
return req.Status
|
||||||
}(),
|
}(),
|
||||||
Types: func() UserTypes {
|
Type: func() UserType {
|
||||||
if len(req.Types) == 0 {
|
if len(req.Type) == 0 {
|
||||||
return UserTypes{"internal"}
|
return UserType("internal")
|
||||||
}
|
}
|
||||||
return req.Types
|
return req.Type
|
||||||
}(),
|
}(),
|
||||||
Avatar: req.Avatar,
|
Avatar: req.Avatar,
|
||||||
UUID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Details: req.Details,
|
Details: req.Details,
|
||||||
Preferences: func() *UserPreferences {
|
Preferences: func() *UserPreferences {
|
||||||
if req.Preferences == nil {
|
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"}))
|
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).
|
// Me returns the authenticated user's profile (short format).
|
||||||
func (uc *UserController) Me(c fiber.Ctx) error {
|
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")
|
tokenString := c.Get("Auth-Token")
|
||||||
if tokenString == "" {
|
if tokenString == "" {
|
||||||
return c.JSON(responses.Success("missing token header"))
|
return fiber.NewError(fiber.StatusForbidden, "missing token header")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := tokenService.ValidateAccessToken(tokenString)
|
claims, err := tokenService.ValidateAccessToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(responses.Success("bad token"))
|
return fiber.NewError(fiber.StatusUnauthorized, "bad token")
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := db.DBFromCtx(c)
|
db, err := db.DBFromCtx(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(responses.Success("failed to load db"))
|
return fiber.NewError(fiber.StatusInternalServerError, "failed to load db")
|
||||||
}
|
}
|
||||||
|
|
||||||
var user User
|
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) {
|
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))
|
return c.JSON(responses.Success(&user))
|
||||||
|
|
@ -678,7 +488,7 @@ func (us *UserController) Refresh(c fiber.Ctx) error {
|
||||||
if claims.TokenType != tokens.TokenTypeRefresh {
|
if claims.TokenType != tokens.TokenTypeRefresh {
|
||||||
return fiber.NewError(fiber.StatusUnauthorized, "refresh token required")
|
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 {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -688,21 +498,18 @@ func (us *UserController) Refresh(c fiber.Ctx) error {
|
||||||
// update user password by Claims
|
// update user password by Claims
|
||||||
func (us *UserController) UpdatePassword(c fiber.Ctx) error {
|
func (us *UserController) UpdatePassword(c fiber.Ctx) error {
|
||||||
var req UpdatePasswordRequest
|
var req UpdatePasswordRequest
|
||||||
|
claims := c.Locals("authClaims")
|
||||||
|
if claims == nil {
|
||||||
|
return fiber.NewError(fiber.StatusForbidden, "forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.Bind().Body(&req); err != nil {
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
|
||||||
}
|
}
|
||||||
if err := validation.ValidateStruct(&req); err != nil {
|
if err := validation.ValidateStruct(&req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := UpdateUserPassword(req, claims.(tokens.Claims).ID); err != nil {
|
||||||
db, err := db.DBFromCtx(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = UpdateUserPassword(db, req, c.Params("uuid"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,60 @@
|
||||||
package users
|
package users
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"server/internal/auth"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"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 {
|
type User struct {
|
||||||
ID int `gorm:"primaryKey"`
|
ID string `json:"id" gorm:"type:uuid;primary_key;"`
|
||||||
Email string `json:"email" gorm:"uniqueIndex;size:255"`
|
Email string `json:"email" gorm:"uniqueIndex;size:255"`
|
||||||
Name string `json:"name" gorm:"size:255"`
|
Name string `json:"name" gorm:"size:255"`
|
||||||
Password string `json:"-" gorm:"size:255"`
|
Password string `json:"-" gorm:"size:255"`
|
||||||
Permission auth.Permission `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
Types UserTypes `json:"types" gorm:"type:text;serializer:json"`
|
Type UserType `json:"type"`
|
||||||
Status UserStatus `json:"status" gorm:"type:text;default:'pending'"`
|
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;"`
|
Details *UserDetails `json:"details" gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||||
Preferences *UserPreferences `json:"preferences" 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"`
|
Avatar *string `json:"avatar" gorm:"size:512"`
|
||||||
|
ActivatedAt *time.Time `json:"activatedAt" ts:"type=Nullable<Date>"`
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty" ts:"type=Date"`
|
CreatedAt *time.Time `json:"createdAt,omitempty" ts:"type=Date"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
|
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
|
||||||
DeletedAt *gorm.DeletedAt `json:"-" gorm:"index" `
|
DeletedAt *gorm.DeletedAt `json:"-" gorm:"index" `
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserTypes is stored as JSON array (e.g. ["internal","external"]).
|
type UpdateUserProfileRequest struct {
|
||||||
type UserTypes []string
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
// UserProfile is the safe full representation of a user returned by CRUD endpoints.
|
Name string `json:"name"`
|
||||||
type UserProfile struct {
|
Permission string `json:"permission"`
|
||||||
ID int `json:"id"`
|
Type UserType `json:"type"`
|
||||||
Email string `json:"email"`
|
Status UserStatus `json:"status"`
|
||||||
Name string `json:"name"`
|
|
||||||
Permission auth.Permission `json:"permission"`
|
|
||||||
Types UserTypes `json:"types"`
|
|
||||||
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,omitempty" ts:"type=Date"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty" ts:"type=Date"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToUserProfile maps a User to a full response without exposing the password hash.
|
type UpdateUserAvatarRequest struct {
|
||||||
func ToUserProfile(u *User) UserProfile {
|
ID string `json:"id" validate:"required,uuid4"`
|
||||||
if u == nil {
|
Img []byte `json:"img"`
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserPreferences holds per-user settings stored as JSON.
|
// UserPreferences holds per-user settings stored as JSON.
|
||||||
|
|
||||||
type UserPreferences struct {
|
type UserPreferences struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
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"`
|
UseIdle bool `json:"useIdle"`
|
||||||
IdleTimeout int `json:"idleTimeout"`
|
IdleTimeout int `json:"idleTimeout"`
|
||||||
UseIdlePassword bool `json:"useIdlePassword"`
|
UseIdlePassword bool `json:"useIdlePassword"`
|
||||||
|
|
@ -84,11 +67,24 @@ type UserPreferences struct {
|
||||||
UpdatedAt *time.Time `json:"updatedAt,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"`
|
||||||
|
IdlePin string `json:"idlePin"`
|
||||||
|
UseDirectLogin bool `json:"useDirectLogin"`
|
||||||
|
UseQuadcodeLogin bool `json:"useQuadcodeLogin"`
|
||||||
|
SendNoticesMail bool `json:"sendNoticesMail"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
// UserDetails holds optional profile data.
|
// UserDetails holds optional profile data.
|
||||||
|
|
||||||
type UserDetails struct {
|
type UserDetails struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
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"`
|
Title string `json:"title"`
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName string `json:"lastName"`
|
LastName string `json:"lastName"`
|
||||||
|
|
@ -101,13 +97,26 @@ type UserDetails struct {
|
||||||
UpdatedAt *time.Time `json:"updatedAt,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"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
City string `json:"city"`
|
||||||
|
ZipCode string `json:"zipCode"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
// UserDetails holds optional profile data.
|
// UserDetails holds optional profile data.
|
||||||
|
|
||||||
// Session tracks logins with browser metadata.
|
// Session tracks logins with browser metadata.
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
ID int `gorm:"primaryKey"`
|
||||||
UserID *int `json:"userId" gorm:"index"`
|
UserID *string `json:"userId" gorm:"index"`
|
||||||
Username string `json:"username" gorm:"size:255"`
|
Username string `json:"username" gorm:"size:255"`
|
||||||
AccessTokenHash string `json:"-" gorm:"size:128;index"`
|
AccessTokenHash string `json:"-" gorm:"size:128;index"`
|
||||||
RefreshTokenHash string `json:"-" gorm:"size:128;index"`
|
RefreshTokenHash string `json:"-" gorm:"size:128;index"`
|
||||||
|
|
@ -120,8 +129,8 @@ type Session struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PasswordResetToken struct {
|
type PasswordResetToken struct {
|
||||||
ID int `json:"id" gorm:"primaryKey"`
|
ID int `gorm:"primaryKey"`
|
||||||
UserID int `json:"userId" gorm:"index"`
|
UserID string `json:"userId" gorm:"index"`
|
||||||
TokenHash string `json:"-" gorm:"size:64;uniqueIndex"`
|
TokenHash string `json:"-" gorm:"size:64;uniqueIndex"`
|
||||||
ExpiresAt time.Time `json:"expiresAt,omitempty" ts:"type=Date" gorm:"index"`
|
ExpiresAt time.Time `json:"expiresAt,omitempty" ts:"type=Date" gorm:"index"`
|
||||||
UsedAt *time.Time `json:"usedAt,omitempty" ts:"type=Date"`
|
UsedAt *time.Time `json:"usedAt,omitempty" ts:"type=Date"`
|
||||||
|
|
@ -140,6 +149,16 @@ const (
|
||||||
UserStatusDisabled UserStatus = "disabled"
|
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 {
|
type LoginRequest struct {
|
||||||
Username string `json:"username" validate:"required,email"`
|
Username string `json:"username" validate:"required,email"`
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
|
|
@ -159,20 +178,18 @@ type ResetPasswordRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePasswordRequest struct {
|
type UpdatePasswordRequest struct {
|
||||||
|
ID string `json:"id" validate:"required,uuid4"`
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
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"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
Password string `json:"password" validate:"omitempty,min=8,max=128"`
|
Password string `json:"password" validate:"omitempty,min=8,max=128"`
|
||||||
Permission auth.Permission `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
Status UserStatus `json:"status"`
|
Status UserStatus `json:"status"`
|
||||||
Types UserTypes `json:"types"`
|
Type UserType `json:"type"`
|
||||||
Avatar *string `json:"avatar,omitempty"`
|
|
||||||
Details *UserDetails `json:"details,omitempty"`
|
|
||||||
Preferences *UserPreferences `json:"preferences,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserCreateRequest captures the minimal payload to create a user.
|
// UserCreateRequest captures the minimal payload to create a user.
|
||||||
|
|
@ -181,9 +198,9 @@ type UserCreateRequest struct {
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
Permission auth.Permission `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
Status UserStatus `json:"status"`
|
Status UserStatus `json:"status"`
|
||||||
Types UserTypes `json:"types"`
|
Type UserType `json:"type"`
|
||||||
Avatar *string `json:"avatar,omitempty"`
|
Avatar *string `json:"avatar,omitempty"`
|
||||||
Details *UserDetails `json:"details,omitempty"`
|
Details *UserDetails `json:"details,omitempty"`
|
||||||
Preferences *UserPreferences `json:"preferences,omitempty"`
|
Preferences *UserPreferences `json:"preferences,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -24,8 +24,8 @@ func RegisterUserRoutes(app fiber.Router) {
|
||||||
|
|
||||||
userController := NewUserController(tockenService)
|
userController := NewUserController(tockenService)
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/users/:uuid; name=getUser; method=GET; response=users.User
|
// Typescript: TSEndpoint= path=/api/users/:id; name=getUser; method=GET; response=users.User
|
||||||
app.Get("/users/:uuid", userController.GetUser)
|
app.Get("/users/:id", userController.GetUser)
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/users; name=createUser; method=POST; request=users.UserCreateRequest; response=users.User
|
// Typescript: TSEndpoint= path=/api/users; name=createUser; method=POST; request=users.UserCreateRequest; response=users.User
|
||||||
app.Post("/users", userController.CreateUser)
|
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
|
// Typescript: TSEndpoint= path=/api/users/update; name=updateUser; method=PUT; request=users.UpdateUserRequest; response=users.User
|
||||||
app.Put("/users/update", userController.UpdateUser)
|
app.Put("/users/update", userController.UpdateUser)
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/users/:uuid; name=deleteUser; method=DELETE; response=responses.SimpleResponse
|
// Typescript: TSEndpoint= path=/api/users/update/details; name=updateUserDetails; method=PUT; request=users.UpdateUserDetailsRequest; response=users.User
|
||||||
app.Delete("/users/:uuid", userController.DeleteUser)
|
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
|
// Typescript: TSEndpoint= path=/api/auth/me; name=me; method=GET; response=users.User
|
||||||
app.Get("/auth/me", middleware.AuthMe, userController.Me)
|
app.Get("/auth/me", middleware.AuthMe, userController.Me)
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
GO_PROJECT_DIR=/Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/http/static
|
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
|
# 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
|
# GO_SPA_TARGET_DIR=/absolute/path/to/your/go/project/spa
|
||||||
|
SEED=0
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export default defineConfig((ctx) => {
|
||||||
// directives: [],
|
// directives: [],
|
||||||
|
|
||||||
// Quasar plugins
|
// Quasar plugins
|
||||||
plugins: ['Notify', 'Dialog', 'Loading'],
|
plugins: ['Notify', 'Dialog', 'Loading', 'AppVisibility'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// animations: 'all', // --- includes all animations
|
// animations: 'all', // --- includes all animations
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
import { resolveI18nLocale, usePreferencesStore } from 'src/stores/preferences-store';
|
import { resolveI18nLocale, usePreferencesStore } from 'src/stores/preferences-store';
|
||||||
|
import { useUserStore } from 'src/stores/user-store';
|
||||||
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const preferencesStore = usePreferencesStore();
|
const preferencesStore = usePreferencesStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const $q = useQuasar()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => preferencesStore.language,
|
() => preferencesStore.language,
|
||||||
|
|
@ -18,4 +21,16 @@ watch(
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,21 @@ import { api } from "./api";
|
||||||
import type { Nullable } from "./apiTypes.ts";
|
import type { Nullable } from "./apiTypes.ts";
|
||||||
import type * as users from "./users.ts";
|
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: 13
|
||||||
|
export const listUsers = async (
|
||||||
|
data: ListUsersRequest,
|
||||||
|
): Promise<{ data: ListUsersResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/api/admin/users", data)) as {
|
||||||
|
data: ListUsersResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/admin/users/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=users.User
|
// 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 (
|
export const blockUser = async (
|
||||||
data: BlockUserRequest,
|
data: BlockUserRequest,
|
||||||
): Promise<{ data: users.User; error: Nullable<string> }> => {
|
): Promise<{ data: users.User; error: Nullable<string> }> => {
|
||||||
|
|
@ -14,14 +26,38 @@ export const blockUser = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=admin.ListUsersResponse
|
// 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: 11
|
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/admin/routes.go Line: 23
|
||||||
export const listUsers = async (
|
export const updateUser = async (
|
||||||
data: ListUsersRequest,
|
data: users.UpdateUserRequest,
|
||||||
): Promise<{ data: ListUsersResponse; error: Nullable<string> }> => {
|
): Promise<{ data: users.User; error: Nullable<string> }> => {
|
||||||
return (await api.POST("/api/admin/users", data)) as {
|
return (await api.PUT("/api/admin/updateUser", data)) as {
|
||||||
data: ListUsersResponse;
|
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>;
|
error: Nullable<string>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -33,7 +69,7 @@ export interface ListUsersResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockUserRequest {
|
export interface BlockUserRequest {
|
||||||
uuid: string;
|
id: string;
|
||||||
action: string;
|
action: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//
|
//
|
||||||
// This file was generated by github.com/millevolte/ts-rpc
|
// This file was generated by github.com/millevolte/ts-rpc
|
||||||
//
|
//
|
||||||
// May 05, 2026 14:14:16 UTC
|
// May 08, 2026 12:28:15 UTC
|
||||||
//
|
//
|
||||||
|
|
||||||
export interface ApiRestResponse {
|
export interface ApiRestResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,12 @@
|
||||||
import { api } from "./api";
|
export type UserRole = (typeof EnumUserRole)[keyof typeof EnumUserRole];
|
||||||
import type { Nullable, Record } from "./apiTypes.ts";
|
|
||||||
|
|
||||||
// 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 EnumUserRole = {
|
||||||
export const getRoles = async (): Promise<{
|
SuperAdminRole: "superadmin",
|
||||||
data: AllRoles;
|
AdminRole: "admin",
|
||||||
error: Nullable<string>;
|
ManagerRole: "manager",
|
||||||
}> => {
|
ContentCreatorRole: "content_creator",
|
||||||
return (await api.GET("/api/roles")) as {
|
UserRole: "user",
|
||||||
data: AllRoles;
|
GuestRole: "guest",
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
export type EnumPermission =
|
|
||||||
(typeof EnumEnumPermission)[keyof typeof EnumEnumPermission];
|
|
||||||
|
|
||||||
export type Permission = string;
|
|
||||||
|
|
||||||
export type AllRoles = Record<string, string>;
|
|
||||||
|
|
||||||
export const EnumEnumPermission = {
|
|
||||||
RoleSuperAdmin: "superadmin",
|
|
||||||
RoleAdmin: "admin",
|
|
||||||
RoleManager: "manager",
|
|
||||||
RoleContentCreator: "content_creator",
|
|
||||||
RoleUser: "user",
|
|
||||||
RoleGuest: "guest",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import type { Nullable } from "./apiTypes.ts";
|
import type { Nullable } from "./apiTypes.ts";
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
||||||
|
|
||||||
|
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/systemUtils/routes.go Line: 36
|
||||||
|
export const health = async (): Promise<{
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
}> => {
|
||||||
|
return (await api.GET("/health")) as {
|
||||||
|
data: string;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
|
||||||
|
|
||||||
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/systemUtils/routes.go Line: 39
|
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/systemUtils/routes.go Line: 39
|
||||||
|
|
@ -27,19 +40,6 @@ export const mailDebug = async (): Promise<{
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
|
|
||||||
|
|
||||||
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/systemUtils/routes.go Line: 36
|
|
||||||
export const health = async (): Promise<{
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
}> => {
|
|
||||||
return (await api.GET("/health")) as {
|
|
||||||
data: string;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MailDebugItem {
|
export interface MailDebugItem {
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,11 @@
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import type { Nullable } from "./apiTypes.ts";
|
import type { Nullable } from "./apiTypes.ts";
|
||||||
import type * as auth from "./auth.ts";
|
|
||||||
import type * as responses from "./responses.ts";
|
|
||||||
import type * as tokens from "./tokens.ts";
|
import type * as tokens from "./tokens.ts";
|
||||||
|
import type * as responses from "./responses.ts";
|
||||||
// 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/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
|
|
||||||
export const validToken = async (
|
|
||||||
data: string,
|
|
||||||
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/api/auth/password/valid", data)) as {
|
|
||||||
data: responses.SimpleResponse;
|
|
||||||
error: Nullable<string>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/auth/me; name=me; method=GET; response=users.User
|
// 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<{
|
export const me = async (): Promise<{
|
||||||
data: User;
|
data: User;
|
||||||
error: Nullable<string>;
|
error: Nullable<string>;
|
||||||
|
|
@ -41,21 +16,9 @@ export const me = async (): Promise<{
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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/auth/password/update; name=updatePassword; method=PUT; request=users.UpdatePasswordRequest; response=responses.SimpleResponse
|
// 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 (
|
export const updatePassword = async (
|
||||||
data: UpdatePasswordRequest,
|
data: UpdatePasswordRequest,
|
||||||
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
|
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
|
@ -65,13 +28,121 @@ export const updatePassword = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Typescript: TSEndpoint= path=/api/users/:uuid; name=getUser; method=GET; response=users.User
|
// 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
|
||||||
|
export const createUser = async (
|
||||||
|
data: UserCreateRequest,
|
||||||
|
): Promise<{ data: User; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/api/users", data)) as {
|
||||||
|
data: User;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: 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/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: 52
|
||||||
|
export const register = async (
|
||||||
|
data: UserCreateRequest,
|
||||||
|
): Promise<{ data: User; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/api/auth/register", data)) as {
|
||||||
|
data: User;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: 61
|
||||||
|
export const validToken = async (
|
||||||
|
data: string,
|
||||||
|
): Promise<{ data: responses.SimpleResponse; error: Nullable<string> }> => {
|
||||||
|
return (await api.POST("/api/auth/password/valid", data)) as {
|
||||||
|
data: responses.SimpleResponse;
|
||||||
|
error: Nullable<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typescript: TSEndpoint= path=/api/users/:id; name=getUser; method=GET; response=users.User
|
||||||
|
|
||||||
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 28
|
// /Users/fabio/CODE/omnimed/go-quasar-partial-ssr/backend/internal/user/routes.go Line: 28
|
||||||
export const getUser = async (
|
export const getUser = async (
|
||||||
uuid: string,
|
id: string,
|
||||||
): Promise<{ data: User; error: Nullable<string> }> => {
|
): Promise<{ data: User; error: Nullable<string> }> => {
|
||||||
return (await api.GET(`/api/users/${uuid}`)) as {
|
return (await api.GET(`/api/users/${id}`)) as {
|
||||||
data: User;
|
data: User;
|
||||||
error: Nullable<string>;
|
error: Nullable<string>;
|
||||||
};
|
};
|
||||||
|
|
@ -89,109 +160,9 @@ export const updateUser = 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/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: 43
|
|
||||||
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/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
|
|
||||||
export const register = async (
|
|
||||||
data: UserCreateRequest,
|
|
||||||
): Promise<{ data: User; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/api/auth/register", data)) as {
|
|
||||||
data: User;
|
|
||||||
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
|
|
||||||
export const createUser = async (
|
|
||||||
data: UserCreateRequest,
|
|
||||||
): Promise<{ data: User; error: Nullable<string> }> => {
|
|
||||||
return (await api.POST("/api/users", 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: 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>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPreferences {
|
|
||||||
id: number;
|
|
||||||
userId: number;
|
|
||||||
useIdle: boolean;
|
|
||||||
idleTimeout: number;
|
|
||||||
useIdlePassword: boolean;
|
|
||||||
idlePin: string;
|
|
||||||
useDirectLogin: boolean;
|
|
||||||
useQuadcodeLogin: boolean;
|
|
||||||
sendNoticesMail: boolean;
|
|
||||||
language: string;
|
|
||||||
createdAt: Nullable<Date>;
|
|
||||||
updatedAt: Nullable<Date>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RefreshRequest {
|
|
||||||
refresh_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResetPasswordRequest {
|
|
||||||
token: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePasswordRequest {
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserDetails {
|
export interface UserDetails {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: string;
|
||||||
title: string;
|
title: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
@ -200,39 +171,78 @@ export interface UserDetails {
|
||||||
zipCode: string;
|
zipCode: string;
|
||||||
country: string;
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
createdAt: Nullable<Date>;
|
createdAt?: Date;
|
||||||
updatedAt: Nullable<Date>;
|
updatedAt?: Date;
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
uuid: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
permission: auth.Permission;
|
permission: string;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
types: UserTypes;
|
type: UserType;
|
||||||
avatar: Nullable<string>;
|
}
|
||||||
details: Nullable<UserDetails>;
|
|
||||||
preferences: Nullable<UserPreferences>;
|
export interface UpdateUserDetailsRequest {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
zipCode: string;
|
||||||
|
country: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshRequest {
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserPreferencesRequest {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
useIdle: boolean;
|
||||||
|
idleTimeout: number;
|
||||||
|
useIdlePassword: boolean;
|
||||||
|
idlePin: string;
|
||||||
|
useDirectLogin: boolean;
|
||||||
|
useQuadcodeLogin: boolean;
|
||||||
|
sendNoticesMail: boolean;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface User {
|
||||||
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
permission: auth.Permission;
|
permission: string;
|
||||||
types: UserTypes;
|
type: UserType;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
activatedAt: Nullable<Date>;
|
|
||||||
uuid: string;
|
|
||||||
details: Nullable<UserDetails>;
|
details: Nullable<UserDetails>;
|
||||||
preferences: Nullable<UserPreferences>;
|
preferences: Nullable<UserPreferences>;
|
||||||
avatar: Nullable<string>;
|
avatar: Nullable<string>;
|
||||||
|
activatedAt: Nullable<Date>;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -241,12 +251,45 @@ export interface ForgotPasswordRequest {
|
||||||
email: string;
|
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 UserStatus = string;
|
||||||
|
|
||||||
export type UserTypes = string[];
|
export type UserType = string;
|
||||||
|
|
||||||
export const EnumUserStatus = {
|
export const EnumUserStatus = {
|
||||||
UserStatusPending: "pending",
|
UserStatusPending: "pending",
|
||||||
UserStatusActive: "active",
|
UserStatusActive: "active",
|
||||||
UserStatusDisabled: "disabled",
|
UserStatusDisabled: "disabled",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const EnumUserTypes = {
|
||||||
|
UserTypeInternal: "internal",
|
||||||
|
UserTypeExternal: "external",
|
||||||
|
UserTypeSurveyor: "surveyor",
|
||||||
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</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-drawer>
|
||||||
|
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
|
|
@ -55,13 +59,28 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useUserStore } from 'src/stores/user-store';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const leftDrawerOpen = ref(false);
|
const leftDrawerOpen = ref(false);
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
function toggleLeftDrawer() {
|
function toggleLeftDrawer() {
|
||||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => userStore.userCounter,
|
||||||
|
() => {
|
||||||
|
if (!userStore.userData) {
|
||||||
|
void router.push('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -117,25 +117,25 @@
|
||||||
<q-btn flat round dense icon="more_vert" color="grey-8">
|
<q-btn flat round dense icon="more_vert" color="grey-8">
|
||||||
<q-menu anchor="bottom right" self="top right">
|
<q-menu anchor="bottom right" self="top right">
|
||||||
<q-list dense class="user-action-menu">
|
<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-item-section avatar>
|
||||||
<q-icon name="visibility" />
|
<q-icon name="visibility" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>Show</q-item-section>
|
<q-item-section>Show</q-item-section>
|
||||||
</q-item>
|
</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-item-section avatar>
|
||||||
<q-icon name="edit" />
|
<q-icon name="edit" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>Edit</q-item-section>
|
<q-item-section>Edit</q-item-section>
|
||||||
</q-item>
|
</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-item-section avatar>
|
||||||
<q-icon name="add_a_photo" />
|
<q-icon name="add_a_photo" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
<q-item-section>Edit avatar</q-item-section>
|
<q-item-section>Edit avatar</q-item-section>
|
||||||
</q-item>
|
</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-item-section avatar>
|
||||||
<q-icon name="password" />
|
<q-icon name="password" />
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
@ -167,415 +167,68 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="dialogOpen">
|
<UserEditorDialog
|
||||||
<q-card class="editor-card modal-card">
|
v-model="dialogOpen"
|
||||||
<q-form class="form-grid" @submit.prevent="saveUser">
|
:dialog-mode="dialogMode"
|
||||||
<q-card-section class="editor-header">
|
:user-id="editorDialogUserId"
|
||||||
<div class="editor-toolbar">
|
@saved="loadUsers"
|
||||||
<div class="editor-headline">
|
@open-avatar="openAvatarDialog"
|
||||||
<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">
|
<UserPasswordDialog
|
||||||
<q-btn flat color="white" label="Chiudi" v-close-popup />
|
v-model="passwordDialogOpen"
|
||||||
<q-btn
|
:user-id="passwordDialogUserId"
|
||||||
v-if="dialogMode !== 'view'"
|
@saved="loadUsers"
|
||||||
color="white"
|
/>
|
||||||
text-color="primary"
|
|
||||||
unelevated
|
|
||||||
:loading="saving"
|
|
||||||
:label="dialogMode === 'create' ? 'Crea utente' : 'Salva modifiche'"
|
|
||||||
type="submit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-tabs
|
<UserBlockDialog
|
||||||
v-model="editorTab"
|
v-model="blockDialogOpen"
|
||||||
dense
|
:user-id="blockDialogUserId"
|
||||||
align="left"
|
@saved="loadUsers"
|
||||||
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 />
|
<UserAvatarDialog
|
||||||
|
v-model="avatarDialogOpen"
|
||||||
<q-card-section class="editor-body">
|
:user-id="avatarDialogUserId"
|
||||||
<q-tab-panels v-model="editorTab" animated class="editor-panels">
|
@saved="loadUsers"
|
||||||
<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
|
|
||||||
? 'L’utente non potra piu accedere finche non verra sbloccato.'
|
|
||||||
: 'L’utente 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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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 un’immagine per modificare l’avatar.
|
|
||||||
</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>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 {
|
import {
|
||||||
blockUser,
|
|
||||||
listUsers,
|
listUsers,
|
||||||
type BlockUserRequest,
|
|
||||||
type ListUsersRequest,
|
type ListUsersRequest,
|
||||||
}from 'src/api/admin';
|
}from 'src/api/admin';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createUser,
|
|
||||||
getUser,
|
|
||||||
updateUser,
|
|
||||||
EnumUserStatus,
|
EnumUserStatus,
|
||||||
type UpdateUserRequest,
|
|
||||||
type UserCreateRequest,
|
|
||||||
type User,
|
type User,
|
||||||
type UserStatus,
|
type UserStatus,
|
||||||
type UserDetails,
|
|
||||||
type UserPreferences,
|
|
||||||
} from 'src/api/users';
|
} 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 $q = useQuasar();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
|
||||||
const dialogOpen = ref(false);
|
const dialogOpen = ref(false);
|
||||||
const passwordDialogOpen = ref(false);
|
const passwordDialogOpen = ref(false);
|
||||||
const blockDialogOpen = ref(false);
|
const blockDialogOpen = ref(false);
|
||||||
const avatarDialogOpen = ref(false);
|
const avatarDialogOpen = ref(false);
|
||||||
const dialogMode = ref<DialogMode>('create');
|
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 filter = ref('');
|
||||||
const rows = ref<User[]>([]);
|
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']>({
|
const pagination = ref<QTableProps['pagination']>({
|
||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
descending: false,
|
descending: false,
|
||||||
|
|
@ -584,217 +237,25 @@ const pagination = ref<QTableProps['pagination']>({
|
||||||
rowsNumber: 0,
|
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>[] = [
|
const columns: QTableColumn<User>[] = [
|
||||||
{ name: 'name', label: 'Utente', field: 'name', align: 'left', sortable: true },
|
{ name: 'name', label: 'Utente', field: 'name', align: 'left', sortable: true },
|
||||||
{ name: 'status', label: 'Status', field: 'status', 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: 'permission', label: 'Roles', field: (row) => row.permission, align: 'left' },
|
||||||
{ name: 'details', label: 'Details', field: (row) => fullName(row), align: 'left' },
|
{ name: 'details', label: 'Details', field: (row) => fullName(row), align: 'left' },
|
||||||
{ name: 'preferences', label: 'Preferences', field: (row) => row.preferences?.language ?? '', 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 () => {
|
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 {
|
function fullName(user: Pick<User, 'details'>): string {
|
||||||
const parts = [user.details?.title, user.details?.firstName, user.details?.lastName].filter(Boolean);
|
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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const request: ListUsersRequest = {
|
const request: ListUsersRequest = {
|
||||||
|
|
@ -848,9 +309,11 @@ async function loadUsers(): Promise<void> {
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error);
|
notifyError(error);
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
loading.value = false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTableRequest(props: { pagination: NonNullable<QTableProps['pagination']> }): Promise<void> {
|
async function onTableRequest(props: { pagination: NonNullable<QTableProps['pagination']> }): Promise<void> {
|
||||||
|
|
@ -858,246 +321,49 @@ async function onTableRequest(props: { pagination: NonNullable<QTableProps['pagi
|
||||||
...pagination.value,
|
...pagination.value,
|
||||||
...props.pagination,
|
...props.pagination,
|
||||||
};
|
};
|
||||||
await loadUsers();
|
const err = await loadUsers();
|
||||||
|
if (!err) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore durante il caricamento degli utenti',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateDialog(): void {
|
function openCreateDialog(): void {
|
||||||
dialogMode.value = 'create';
|
dialogMode.value = 'create';
|
||||||
editorTab.value = 'account';
|
editorDialogUserId.value = '';
|
||||||
resetForm();
|
|
||||||
dialogOpen.value = true;
|
dialogOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEditDialog(uuid: string): Promise<void> {
|
function openEditDialog(id: string): void {
|
||||||
await openUserDialog('edit', uuid);
|
dialogMode.value = 'edit';
|
||||||
|
editorDialogUserId.value = id;
|
||||||
|
dialogOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openViewDialog(uuid: string): Promise<void> {
|
function openViewDialog(id: string): void {
|
||||||
await openUserDialog('view', uuid);
|
dialogMode.value = 'view';
|
||||||
|
editorDialogUserId.value = id;
|
||||||
|
dialogOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPasswordDialog(uuid: string): Promise<void> {
|
function openPasswordDialog(id: string): void {
|
||||||
loading.value = true;
|
passwordDialogUserId.value = id;
|
||||||
try {
|
passwordDialogOpen.value = true;
|
||||||
const profile = await resolveUserProfile(uuid);
|
|
||||||
passwordDialogUserUuid.value = profile.uuid;
|
|
||||||
passwordDialogUserEmail.value = profile.email;
|
|
||||||
passwordForm.password = '';
|
|
||||||
passwordForm.confirmPassword = '';
|
|
||||||
passwordDialogOpen.value = true;
|
|
||||||
} catch (error) {
|
|
||||||
notifyError(error);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAvatarDialog(uuid: string): Promise<void> {
|
function openAvatarDialog(id: string): void {
|
||||||
loading.value = true;
|
avatarDialogUserId.value = id;
|
||||||
try {
|
avatarDialogOpen.value = true;
|
||||||
const profile = await resolveUserProfile(uuid);
|
|
||||||
avatarDialogUser.uuid = profile.uuid;
|
|
||||||
avatarDialogUser.email = profile.email;
|
|
||||||
avatarFile.value = null;
|
|
||||||
avatarSource.value = profile.avatar ?? '';
|
|
||||||
avatarPreview.value = profile.avatar ?? '';
|
|
||||||
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 un’immagine 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
|
function openBlockDialog(user: User
|
||||||
): void {
|
): void {
|
||||||
blockDialogUser.uuid = user.uuid;
|
blockDialogUserId.value = user.id;
|
||||||
blockDialogUser.email = user.email;
|
|
||||||
blockDialogUser.status = user.status;
|
|
||||||
blockDialogBlocked.value = user.status === EnumUserStatus.UserStatusDisabled;
|
|
||||||
blockDialogOpen.value = true;
|
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 {
|
function notifyError(error: unknown): void {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
|
|
|
||||||
|
|
@ -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 un’immagine per modificare l’avatar.
|
||||||
|
</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 un’immagine 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>
|
||||||
|
|
@ -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
|
||||||
|
? 'L’utente non potra piu accedere finche non verra sbloccato.'
|
||||||
|
: 'L’utente 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
|
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useQuasar } from 'quasar';
|
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 $q = useQuasar();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
@ -113,9 +113,11 @@ async function submit(): Promise<void> {
|
||||||
password: form.password,
|
password: form.password,
|
||||||
permission: "user",
|
permission: "user",
|
||||||
status: EnumUserStatus.UserStatusPending,
|
status: EnumUserStatus.UserStatusPending,
|
||||||
types: ['internal'],
|
type: EnumUserTypes.UserTypeInternal,
|
||||||
avatar: null,
|
avatar: null,
|
||||||
details: {
|
details: {
|
||||||
|
id: 0,
|
||||||
|
userId: '',
|
||||||
title: '',
|
title: '',
|
||||||
firstName: form.firstName.trim(),
|
firstName: form.firstName.trim(),
|
||||||
lastName: form.lastName.trim(),
|
lastName: form.lastName.trim(),
|
||||||
|
|
@ -124,10 +126,6 @@ async function submit(): Promise<void> {
|
||||||
zipCode: '',
|
zipCode: '',
|
||||||
country: '',
|
country: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
id: 0,
|
|
||||||
userId: 0,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
},
|
||||||
preferences: null,
|
preferences: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue