feat: add role validation schema and validation field
This commit is contained in:
parent
12f18c529b
commit
15c0e5ab85
|
@ -17,3 +17,6 @@ REDIS_DB=
|
||||||
|
|
||||||
# Keyauth
|
# Keyauth
|
||||||
API_KEY=
|
API_KEY=
|
||||||
|
|
||||||
|
#SECRET_KEY
|
||||||
|
SECRET_KEY=
|
||||||
|
|
|
@ -30,7 +30,10 @@ func ConnectDatabase() {
|
||||||
}
|
}
|
||||||
log.Println("Database connected successfully!")
|
log.Println("Database connected successfully!")
|
||||||
|
|
||||||
err = DB.AutoMigrate(&model.User{})
|
err = DB.AutoMigrate(
|
||||||
|
&model.User{},
|
||||||
|
&model.Role{},
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error performing auto-migration: %v", err)
|
log.Fatalf("Error performing auto-migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginDTO struct {
|
type LoginDTO struct {
|
||||||
Identifier string `json:"identifier" validate:"required"`
|
RoleID string `json:"roleid"`
|
||||||
Password string `json:"password" validate:"required,min=6"`
|
Identifier string `json:"identifier"`
|
||||||
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserResponseWithToken struct {
|
type UserResponseWithToken struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Token string `json:"token"`
|
RoleName string `json:"loginas"`
|
||||||
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegisterDTO struct {
|
type RegisterDTO struct {
|
||||||
|
@ -22,6 +24,7 @@ type RegisterDTO struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
ConfirmPassword string `json:"confirm_password"`
|
ConfirmPassword string `json:"confirm_password"`
|
||||||
|
RoleID string `json:"roleId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserResponseDTO struct {
|
type UserResponseDTO struct {
|
||||||
|
@ -31,10 +34,30 @@ type UserResponseDTO struct {
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
EmailVerified bool `json:"emailVerified"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
|
RoleName string `json:"role"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *LoginDTO) Validate() (map[string][]string, bool) {
|
||||||
|
errors := make(map[string][]string)
|
||||||
|
|
||||||
|
if strings.TrimSpace(l.RoleID) == "" {
|
||||||
|
errors["roleid"] = append(errors["roleid"], "Role ID is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(l.Identifier) == "" {
|
||||||
|
errors["identifier"] = append(errors["identifier"], "Identifier (username, email, or phone) is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(l.Password) == "" {
|
||||||
|
errors["password"] = append(errors["password"], "Password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return errors, false
|
||||||
|
}
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
func (r *RegisterDTO) Validate() (map[string][]string, bool) {
|
func (r *RegisterDTO) Validate() (map[string][]string, bool) {
|
||||||
errors := make(map[string][]string)
|
errors := make(map[string][]string)
|
||||||
|
|
||||||
|
@ -68,6 +91,9 @@ func (r *RegisterDTO) Validate() (map[string][]string, bool) {
|
||||||
} else if r.Password != r.ConfirmPassword {
|
} else if r.Password != r.ConfirmPassword {
|
||||||
errors["confirm_password"] = append(errors["confirm_password"], "Password and confirm password do not match")
|
errors["confirm_password"] = append(errors["confirm_password"], "Password and confirm password do not match")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(r.RoleID) == "" {
|
||||||
|
errors["roleId"] = append(errors["roleId"], "RoleID is required")
|
||||||
|
}
|
||||||
|
|
||||||
if len(errors) > 0 {
|
if len(errors) > 0 {
|
||||||
return errors, false
|
return errors, false
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/pahmiudahgede/senggoldong/dto"
|
"github.com/pahmiudahgede/senggoldong/dto"
|
||||||
"github.com/pahmiudahgede/senggoldong/internal/services"
|
"github.com/pahmiudahgede/senggoldong/internal/services"
|
||||||
|
@ -22,18 +24,23 @@ func (h *UserHandler) Login(c *fiber.Ctx) error {
|
||||||
return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}})
|
return utils.ValidationErrorResponse(c, map[string][]string{"body": {"Invalid body"}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validationErrors, valid := loginDTO.Validate()
|
||||||
|
if !valid {
|
||||||
|
return utils.ValidationErrorResponse(c, validationErrors)
|
||||||
|
}
|
||||||
|
|
||||||
user, err := h.UserService.Login(loginDTO)
|
user, err := h.UserService.Login(loginDTO)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "user not found" {
|
if err.Error() == "akun dengan role tersebut belum terdaftar" {
|
||||||
|
return utils.GenericErrorResponse(c, fiber.StatusNotFound, "akun dengan role tersebut belum terdaftar")
|
||||||
return utils.ErrorResponse(c, "User not found")
|
|
||||||
}
|
}
|
||||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
if err.Error() == "password yang anda masukkan salah" {
|
||||||
|
return utils.GenericErrorResponse(c, fiber.StatusUnauthorized, "password yang anda masukkan salah")
|
||||||
return utils.ErrorResponse(c, "Invalid password")
|
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
return utils.InternalServerErrorResponse(c, "Error logging in")
|
return utils.GenericErrorResponse(c, fiber.StatusUnauthorized, "password yang anda masukkan salah")
|
||||||
|
}
|
||||||
|
return utils.GenericErrorResponse(c, fiber.StatusNotFound, "akun tidak ditemukan")
|
||||||
}
|
}
|
||||||
|
|
||||||
return utils.LogResponse(c, user, "Login successful")
|
return utils.LogResponse(c, user, "Login successful")
|
||||||
|
@ -46,9 +53,7 @@ func (h *UserHandler) Register(c *fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
errors, valid := registerDTO.Validate()
|
errors, valid := registerDTO.Validate()
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
|
|
||||||
return utils.ValidationErrorResponse(c, errors)
|
return utils.ValidationErrorResponse(c, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,15 +62,8 @@ func (h *UserHandler) Register(c *fiber.Ctx) error {
|
||||||
return utils.ErrorResponse(c, err.Error())
|
return utils.ErrorResponse(c, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
createdAt, err := utils.FormatDateToIndonesianFormat(user.CreatedAt)
|
createdAt, _ := utils.FormatDateToIndonesianFormat(user.CreatedAt)
|
||||||
if err != nil {
|
updatedAt, _ := utils.FormatDateToIndonesianFormat(user.UpdatedAt)
|
||||||
return utils.InternalServerErrorResponse(c, "Error formatting created date")
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedAt, err := utils.FormatDateToIndonesianFormat(user.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return utils.InternalServerErrorResponse(c, "Error formatting updated date")
|
|
||||||
}
|
|
||||||
|
|
||||||
userResponse := dto.UserResponseDTO{
|
userResponse := dto.UserResponseDTO{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
|
@ -74,6 +72,7 @@ func (h *UserHandler) Register(c *fiber.Ctx) error {
|
||||||
Phone: user.Phone,
|
Phone: user.Phone,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
EmailVerified: user.EmailVerified,
|
EmailVerified: user.EmailVerified,
|
||||||
|
RoleName: user.Role.RoleName,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
UpdatedAt: updatedAt,
|
UpdatedAt: updatedAt,
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
package handler
|
|
@ -6,7 +6,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
|
FindByIdentifierAndRole(identifier, roleID string) (*model.User, error)
|
||||||
FindByEmailOrUsernameOrPhone(identifier string) (*model.User, error)
|
FindByEmailOrUsernameOrPhone(identifier string) (*model.User, error)
|
||||||
|
FindByUsername(username string) (*model.User, error)
|
||||||
|
FindByPhoneAndRole(phone, roleID string) (*model.User, error)
|
||||||
|
FindByEmailAndRole(email, roleID string) (*model.User, error)
|
||||||
Create(user *model.User) error
|
Create(user *model.User) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +22,42 @@ func NewUserRepository(db *gorm.DB) UserRepository {
|
||||||
return &userRepository{DB: db}
|
return &userRepository{DB: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) FindByIdentifierAndRole(identifier, roleID string) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.DB.Preload("Role").Where("(email = ? OR username = ? OR phone = ?) AND role_id = ?", identifier, identifier, identifier, roleID).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.DB.Where("username = ?", username).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) FindByPhoneAndRole(phone, roleID string) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.DB.Where("phone = ? AND role_id = ?", phone, roleID).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) FindByEmailAndRole(email, roleID string) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.DB.Where("email = ? AND role_id = ?", email, roleID).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userRepository) FindByEmailOrUsernameOrPhone(identifier string) (*model.User, error) {
|
func (r *userRepository) FindByEmailOrUsernameOrPhone(identifier string) (*model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := r.DB.Where("email = ? OR username = ? OR phone = ?", identifier, identifier, identifier).First(&user).Error
|
err := r.DB.Where("email = ? OR username = ? OR phone = ?", identifier, identifier, identifier).First(&user).Error
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pahmiudahgede/senggoldong/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleRepository interface {
|
||||||
|
FindByID(id string) (*model.Role, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roleRepository struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoleRepository(db *gorm.DB) RoleRepository {
|
||||||
|
return &roleRepository{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roleRepository) FindByID(id string) (*model.Role, error) {
|
||||||
|
var role model.Role
|
||||||
|
err := r.DB.Where("id = ?", id).First(&role).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &role, nil
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -19,23 +20,26 @@ type UserService interface {
|
||||||
|
|
||||||
type userService struct {
|
type userService struct {
|
||||||
UserRepo repositories.UserRepository
|
UserRepo repositories.UserRepository
|
||||||
|
RoleRepo repositories.RoleRepository
|
||||||
SecretKey string
|
SecretKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserService(userRepo repositories.UserRepository, secretKey string) UserService {
|
func NewUserService(userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, secretKey string) UserService {
|
||||||
return &userService{UserRepo: userRepo, SecretKey: secretKey}
|
return &userService{UserRepo: userRepo, RoleRepo: roleRepo, SecretKey: secretKey}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) Login(credentials dto.LoginDTO) (*dto.UserResponseWithToken, error) {
|
func (s *userService) Login(credentials dto.LoginDTO) (*dto.UserResponseWithToken, error) {
|
||||||
|
if credentials.RoleID == "" {
|
||||||
|
return nil, errors.New("roleId is required")
|
||||||
|
}
|
||||||
|
|
||||||
user, err := s.UserRepo.FindByEmailOrUsernameOrPhone(credentials.Identifier)
|
user, err := s.UserRepo.FindByIdentifierAndRole(credentials.Identifier, credentials.RoleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return nil, errors.New("akun dengan role tersebut belum terdaftar")
|
||||||
return nil, fmt.Errorf("user not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !CheckPasswordHash(credentials.Password, user.Password) {
|
if !CheckPasswordHash(credentials.Password, user.Password) {
|
||||||
return nil, bcrypt.ErrMismatchedHashAndPassword
|
return nil, errors.New("password yang anda masukkan salah")
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := s.generateJWT(user)
|
token, err := s.generateJWT(user)
|
||||||
|
@ -49,8 +53,9 @@ func (s *userService) Login(credentials dto.LoginDTO) (*dto.UserResponseWithToke
|
||||||
}
|
}
|
||||||
|
|
||||||
return &dto.UserResponseWithToken{
|
return &dto.UserResponseWithToken{
|
||||||
UserID: user.ID,
|
RoleName: user.Role.RoleName,
|
||||||
Token: token,
|
UserID: user.ID,
|
||||||
|
Token: token,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,11 +82,34 @@ func CheckPasswordHash(password, hashedPassword string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) Register(user dto.RegisterDTO) (*model.User, error) {
|
func (s *userService) Register(user dto.RegisterDTO) (*model.User, error) {
|
||||||
|
|
||||||
if user.Password != user.ConfirmPassword {
|
if user.Password != user.ConfirmPassword {
|
||||||
return nil, fmt.Errorf("password and confirm password do not match")
|
return nil, fmt.Errorf("password and confirm password do not match")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.RoleID == "" {
|
||||||
|
return nil, fmt.Errorf("roleId is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := s.RoleRepo.FindByID(user.RoleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid roleId")
|
||||||
|
}
|
||||||
|
|
||||||
|
existingUser, _ := s.UserRepo.FindByUsername(user.Username)
|
||||||
|
if existingUser != nil {
|
||||||
|
return nil, fmt.Errorf("username is already taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPhone, _ := s.UserRepo.FindByPhoneAndRole(user.Phone, user.RoleID)
|
||||||
|
if existingPhone != nil {
|
||||||
|
return nil, fmt.Errorf("phone number is already used for this role")
|
||||||
|
}
|
||||||
|
|
||||||
|
existingEmail, _ := s.UserRepo.FindByEmailAndRole(user.Email, user.RoleID)
|
||||||
|
if existingEmail != nil {
|
||||||
|
return nil, fmt.Errorf("email is already used for this role")
|
||||||
|
}
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to hash password: %v", err)
|
return nil, fmt.Errorf("failed to hash password: %v", err)
|
||||||
|
@ -93,6 +121,7 @@ func (s *userService) Register(user dto.RegisterDTO) (*model.User, error) {
|
||||||
Phone: user.Phone,
|
Phone: user.Phone,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Password: string(hashedPassword),
|
Password: string(hashedPassword),
|
||||||
|
RoleID: user.RoleID,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.UserRepo.Create(&newUser)
|
err = s.UserRepo.Create(&newUser)
|
||||||
|
@ -100,5 +129,7 @@ func (s *userService) Register(user dto.RegisterDTO) (*model.User, error) {
|
||||||
return nil, fmt.Errorf("failed to create user: %v", err)
|
return nil, fmt.Errorf("failed to create user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newUser.Role = *role
|
||||||
|
|
||||||
return &newUser, nil
|
return &newUser, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
package services
|
|
@ -0,0 +1,11 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:uuid_generate_v4();unique;not null" json:"id"`
|
||||||
|
RoleName string `gorm:"unique;not null" json:"roleName"`
|
||||||
|
Users []User `gorm:"foreignKey:RoleID" json:"users"`
|
||||||
|
CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"`
|
||||||
|
}
|
|
@ -11,6 +11,8 @@ type User struct {
|
||||||
Email string `gorm:"not null" json:"email"`
|
Email string `gorm:"not null" json:"email"`
|
||||||
EmailVerified bool `gorm:"default:false" json:"emailVerified"`
|
EmailVerified bool `gorm:"default:false" json:"emailVerified"`
|
||||||
Password string `gorm:"not null" json:"password"`
|
Password string `gorm:"not null" json:"password"`
|
||||||
|
RoleID string `gorm:"not null" json:"roleId"`
|
||||||
|
Role Role `gorm:"foreignKey:RoleID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"role"`
|
||||||
CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"`
|
CreatedAt time.Time `gorm:"default:current_timestamp" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"default:current_timestamp" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,9 +19,10 @@ func AuthRouter(app *fiber.App) {
|
||||||
}
|
}
|
||||||
|
|
||||||
userRepo := repositories.NewUserRepository(config.DB)
|
userRepo := repositories.NewUserRepository(config.DB)
|
||||||
userService := services.NewUserService(userRepo, secretKey)
|
roleRepo := repositories.NewRoleRepository(config.DB)
|
||||||
|
userService := services.NewUserService(userRepo, roleRepo, secretKey)
|
||||||
userHandler := handler.NewUserHandler(userService)
|
userHandler := handler.NewUserHandler(userService)
|
||||||
|
|
||||||
api.Post("/login", userHandler.Login)
|
api.Post("/login", userHandler.Login)
|
||||||
api.Post("/register", userHandler.Register)
|
api.Post("/register", userHandler.Register)
|
||||||
api.Post("/logout", userHandler.Logout)
|
api.Post("/logout", userHandler.Logout)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RoleMasyarakat = "63191315-c59f-4af9-91a7-367c698cc486"
|
RoleAdministrator = "46f75bb9-7f64-44b7-b378-091a67b3e229"
|
||||||
RolePengepul = "bda3827a-3c61-459a-9a95-42e2bb88d737"
|
RoleMasyarakat = "6cfa867b-536c-448d-ba11-fe060b5af971"
|
||||||
RolePengelola = "fc75351d-eded-4314-a41b-e4a901e6540c"
|
RolePengepul = "8171883c-ea9e-4d17-9f28-a7896d88380f"
|
||||||
RoleAdministrator = "fe4a15ce-5a0c-40d0-9be0-a7d4b6d05480"
|
RolePengelola = "84d72ddb-68a8-430c-9b79-5d71f90cb1be"
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue