diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 79ab2d9..57d1fda 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -41,6 +41,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { userService := service.NewUserService(db, jwtService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService) testService := service.NewTestService(db, appConfigService) + userGroupService := service.NewUserGroupService(db) r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)) @@ -57,6 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) { controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) + controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) // Add test controller in non-production environments if common.EnvConfig.AppEnv != "production" { diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 3dc657a..d05ac13 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -15,4 +15,5 @@ var ( ErrOidcInvalidCallbackURL = errors.New("invalid callback URL") ErrFileTypeNotSupported = errors.New("file type not supported") ErrInvalidCredentials = errors.New("no user found with provided credentials") + ErrNameAlreadyInUse = errors.New("name is already in use") ) diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go new file mode 100644 index 0000000..0012f80 --- /dev/null +++ b/backend/internal/controller/user_group_controller.go @@ -0,0 +1,162 @@ +package controller + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/stonith404/pocket-id/backend/internal/common" + "github.com/stonith404/pocket-id/backend/internal/dto" + "github.com/stonith404/pocket-id/backend/internal/middleware" + "github.com/stonith404/pocket-id/backend/internal/service" + "github.com/stonith404/pocket-id/backend/internal/utils" +) + +func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) { + ugc := UserGroupController{ + UserGroupService: userGroupService, + } + + group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list) + group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get) + group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create) + group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update) + group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete) + group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers) +} + +type UserGroupController struct { + UserGroupService *service.UserGroupService +} + +func (ugc *UserGroupController) list(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + searchTerm := c.Query("search") + + groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize) + if err != nil { + utils.ControllerError(c, err) + return + } + + var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups)) + for i, group := range groups { + var groupDto dto.UserGroupDtoWithUserCount + if err := dto.MapStruct(group, &groupDto); err != nil { + utils.ControllerError(c, err) + return + } + groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID) + if err != nil { + utils.ControllerError(c, err) + return + } + groupsDto[i] = groupDto + } + + c.JSON(http.StatusOK, gin.H{ + "data": groupsDto, + "pagination": pagination, + }) +} + +func (ugc *UserGroupController) get(c *gin.Context) { + group, err := ugc.UserGroupService.Get(c.Param("id")) + if err != nil { + utils.ControllerError(c, err) + return + } + + var groupDto dto.UserGroupDtoWithUsers + if err := dto.MapStruct(group, &groupDto); err != nil { + utils.ControllerError(c, err) + return + } + + c.JSON(http.StatusOK, groupDto) +} + +func (ugc *UserGroupController) create(c *gin.Context) { + var input dto.UserGroupCreateDto + if err := c.ShouldBindJSON(&input); err != nil { + utils.ControllerError(c, err) + return + } + + group, err := ugc.UserGroupService.Create(input) + if err != nil { + if errors.Is(err, common.ErrNameAlreadyInUse) { + utils.CustomControllerError(c, http.StatusConflict, err.Error()) + } else { + utils.ControllerError(c, err) + } + return + } + + var groupDto dto.UserGroupDtoWithUsers + if err := dto.MapStruct(group, &groupDto); err != nil { + utils.ControllerError(c, err) + return + } + + c.JSON(http.StatusCreated, groupDto) +} + +func (ugc *UserGroupController) update(c *gin.Context) { + var input dto.UserGroupCreateDto + if err := c.ShouldBindJSON(&input); err != nil { + utils.ControllerError(c, err) + return + } + + group, err := ugc.UserGroupService.Update(c.Param("id"), input) + if err != nil { + if errors.Is(err, common.ErrNameAlreadyInUse) { + utils.CustomControllerError(c, http.StatusConflict, err.Error()) + } else { + utils.ControllerError(c, err) + } + return + } + + var groupDto dto.UserGroupDtoWithUsers + if err := dto.MapStruct(group, &groupDto); err != nil { + utils.ControllerError(c, err) + return + } + + c.JSON(http.StatusOK, groupDto) +} + +func (ugc *UserGroupController) delete(c *gin.Context) { + if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil { + utils.ControllerError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +func (ugc *UserGroupController) updateUsers(c *gin.Context) { + var input dto.UserGroupUpdateUsersDto + if err := c.ShouldBindJSON(&input); err != nil { + utils.ControllerError(c, err) + return + } + + group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input) + if err != nil { + utils.ControllerError(c, err) + return + } + + var groupDto dto.UserGroupDtoWithUsers + if err := dto.MapStruct(group, &groupDto); err != nil { + utils.ControllerError(c, err) + return + } + + c.JSON(http.StatusOK, groupDto) +} diff --git a/backend/internal/dto/dto_mapper.go b/backend/internal/dto/dto_mapper.go index b84f1f4..f8718f7 100644 --- a/backend/internal/dto/dto_mapper.go +++ b/backend/internal/dto/dto_mapper.go @@ -57,15 +57,37 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error { // Handle direct assignment for simple types if sourceField.Type() == destField.Type() { destField.Set(sourceField) + } else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice { // Handle slices if sourceField.Type().Elem() == destField.Type().Elem() { + // Direct assignment for slices of primitive types or non-struct elements newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap()) for j := 0; j < sourceField.Len(); j++ { newSlice.Index(j).Set(sourceField.Index(j)) } + destField.Set(newSlice) + + } else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct { + // Recursively map slices of structs + newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap()) + + for j := 0; j < sourceField.Len(); j++ { + // Get the element from both source and destination slice + sourceElem := sourceField.Index(j) + destElem := reflect.New(destField.Type().Elem()).Elem() + + // Recursively map the struct elements + if err := mapStructInternal(sourceElem, destElem); err != nil { + return err + } + + // Set the mapped element in the new slice + newSlice.Index(j).Set(destElem) + } + destField.Set(newSlice) } } else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct { diff --git a/backend/internal/dto/user_group_dto.go b/backend/internal/dto/user_group_dto.go new file mode 100644 index 0000000..424c61c --- /dev/null +++ b/backend/internal/dto/user_group_dto.go @@ -0,0 +1,32 @@ +package dto + +import "time" + +type UserGroupDtoWithUsers struct { + ID string `json:"id"` + FriendlyName string `json:"friendlyName"` + Name string `json:"name"` + Users []UserDto `json:"users"` + CreatedAt time.Time `json:"createdAt"` +} + +type UserGroupDtoWithUserCount struct { + ID string `json:"id"` + FriendlyName string `json:"friendlyName"` + Name string `json:"name"` + UserCount int64 `json:"userCount"` + CreatedAt time.Time `json:"createdAt"` +} + +type UserGroupCreateDto struct { + FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"` + Name string `json:"name" binding:"required,min=3,max=30,userGroupName"` +} + +type UserGroupUpdateUsersDto struct { + UserIDs []string `json:"userIds" binding:"required"` +} + +type AssignUserToGroupDto struct { + UserID string `json:"userId" binding:"required"` +} diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 1f9197f..59a2162 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -28,6 +28,13 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool { return matched } +var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool { + // [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores + regex := "^[a-z0-9_]+$" + matched, _ := regexp.MatchString(regex, fl.Field().String()) + return matched +} + func init() { if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if err := v.RegisterValidation("urlList", validateUrlList); err != nil { @@ -39,4 +46,10 @@ func init() { log.Fatalf("Failed to register custom validation: %v", err) } } + + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + if err := v.RegisterValidation("userGroupName", validateUserGroupName); err != nil { + log.Fatalf("Failed to register custom validation: %v", err) + } + } } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 2d53783..7a62ea5 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -15,6 +15,7 @@ type User struct { LastName string IsAdmin bool + UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` Credentials []WebauthnCredential } diff --git a/backend/internal/model/user_group.go b/backend/internal/model/user_group.go new file mode 100644 index 0000000..8559016 --- /dev/null +++ b/backend/internal/model/user_group.go @@ -0,0 +1,8 @@ +package model + +type UserGroup struct { + Base + FriendlyName string + Name string `gorm:"unique"` + Users []User `gorm:"many2many:user_groups_users;"` +} diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 7236d2b..a72e0e7 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -301,15 +301,21 @@ func (s *OidcService) DeleteClientLogo(clientID string) error { func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) { var authorizedOidcClient model.UserAuthorizedOidcClient - if err := s.db.Preload("User").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil { + if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil { return nil, err } user := authorizedOidcClient.User scope := authorizedOidcClient.Scope + userGroups := make([]string, len(user.UserGroups)) + for i, group := range user.UserGroups { + userGroups[i] = group.Name + } + claims := map[string]interface{}{ - "sub": user.ID, + "sub": user.ID, + "groups": userGroups, } if strings.Contains(scope, "email") { diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go new file mode 100644 index 0000000..6dbd9ad --- /dev/null +++ b/backend/internal/service/user_group_service.go @@ -0,0 +1,111 @@ +package service + +import ( + "errors" + "github.com/stonith404/pocket-id/backend/internal/common" + "github.com/stonith404/pocket-id/backend/internal/dto" + "github.com/stonith404/pocket-id/backend/internal/model" + "github.com/stonith404/pocket-id/backend/internal/utils" + "gorm.io/gorm" +) + +type UserGroupService struct { + db *gorm.DB +} + +func NewUserGroupService(db *gorm.DB) *UserGroupService { + return &UserGroupService{db: db} +} + +func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) { + query := s.db.Model(&model.UserGroup{}) + + if name != "" { + query = query.Where("name LIKE ?", "%"+name+"%") + } + + response, err = utils.Paginate(page, pageSize, query, &groups) + return groups, response, err +} + +func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) { + err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error + return group, err +} + +func (s *UserGroupService) Delete(id string) error { + var group model.UserGroup + if err := s.db.Where("id = ?", id).First(&group).Error; err != nil { + return err + } + + return s.db.Delete(&group).Error +} + +func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.UserGroup, err error) { + group = model.UserGroup{ + FriendlyName: input.FriendlyName, + Name: input.Name, + } + + if err := s.db.Preload("Users").Create(&group).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return model.UserGroup{}, common.ErrNameAlreadyInUse + } + return model.UserGroup{}, err + } + return group, nil +} + +func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) { + group, err = s.Get(id) + if err != nil { + return model.UserGroup{}, err + } + + group.Name = input.Name + group.FriendlyName = input.FriendlyName + + if err := s.db.Preload("Users").Save(&group).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return model.UserGroup{}, common.ErrNameAlreadyInUse + } + return model.UserGroup{}, err + } + return group, nil +} + +func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) { + group, err = s.Get(id) + if err != nil { + return model.UserGroup{}, err + } + + // Fetch the users based on UserIDs in input + var users []model.User + if len(input.UserIDs) > 0 { + if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil { + return model.UserGroup{}, err + } + } + + // Replace the current users with the new set of users + if err := s.db.Model(&group).Association("Users").Replace(users); err != nil { + return model.UserGroup{}, err + } + + // Save the updated group + if err := s.db.Save(&group).Error; err != nil { + return model.UserGroup{}, err + } + + return group, nil +} + +func (s *UserGroupService) GetUserCountOfGroup(id string) (int64, error) { + var group model.UserGroup + if err := s.db.Preload("Users").Where("id = ?", id).First(&group).Error; err != nil { + return 0, err + } + return s.db.Model(&group).Association("Users").Count(), nil +} diff --git a/backend/internal/utils/paging_util.go b/backend/internal/utils/paging_util.go index b998648..ccdf8df 100644 --- a/backend/internal/utils/paging_util.go +++ b/backend/internal/utils/paging_util.go @@ -5,9 +5,10 @@ import ( ) type PaginationResponse struct { - TotalPages int64 `json:"totalPages"` - TotalItems int64 `json:"totalItems"` - CurrentPage int `json:"currentPage"` + TotalPages int64 `json:"totalPages"` + TotalItems int64 `json:"totalItems"` + CurrentPage int `json:"currentPage"` + ItemsPerPage int `json:"itemsPerPage"` } func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) { @@ -33,8 +34,9 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati } return PaginationResponse{ - TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize), - TotalItems: totalItems, - CurrentPage: page, + TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize), + TotalItems: totalItems, + CurrentPage: page, + ItemsPerPage: pageSize, }, nil } diff --git a/backend/migrations/20240924202721_user_groups.down.sql b/backend/migrations/20240924202721_user_groups.down.sql new file mode 100644 index 0000000..1ea40bf --- /dev/null +++ b/backend/migrations/20240924202721_user_groups.down.sql @@ -0,0 +1,2 @@ +DROP TABLE user_groups; +DROP TABLE user_groups_users; \ No newline at end of file diff --git a/backend/migrations/20240924202721_user_groups.up.sql b/backend/migrations/20240924202721_user_groups.up.sql new file mode 100644 index 0000000..405c0a0 --- /dev/null +++ b/backend/migrations/20240924202721_user_groups.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE user_groups +( + id TEXT NOT NULL PRIMARY KEY, + created_at DATETIME, + friendly_name TEXT NOT NULL, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE user_groups_users +( + user_id TEXT NOT NULL, + user_group_id TEXT NOT NULL, + PRIMARY KEY (user_id, user_group_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b793ef..72400e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@simplewebauthn/browser": "^10.0.0", "axios": "^1.7.5", - "bits-ui": "^0.21.13", + "bits-ui": "^0.21.15", "clsx": "^2.1.1", "crypto": "^1.0.1", "formsnap": "^1.0.1", @@ -1806,9 +1806,9 @@ } }, "node_modules/bits-ui": { - "version": "0.21.13", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.13.tgz", - "integrity": "sha512-7nmOh6Ig7ND4DXZHv1FhNsY9yUGrad0+mf3tc4YN//3MgnJT1LnHtk4HZAKgmxCOe7txSX7/39LtYHbkrXokAQ==", + "version": "0.21.15", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.15.tgz", + "integrity": "sha512-+m5WSpJnFdCcNdXSTIVC1WYBozipO03qRh03GFWgrdxoHiolCfwW71EYG4LPCWYPG6KcTZV0Cj6iHSiZ7cdKdg==", "dependencies": { "@internationalized/date": "^3.5.1", "@melt-ui/svelte": "0.76.2", diff --git a/frontend/package.json b/frontend/package.json index 63717b5..bc99359 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,7 @@ "dependencies": { "@simplewebauthn/browser": "^10.0.0", "axios": "^1.7.5", - "bits-ui": "^0.21.13", + "bits-ui": "^0.21.15", "clsx": "^2.1.1", "crypto": "^1.0.1", "formsnap": "^1.0.1", diff --git a/frontend/src/lib/components/advanced-table.svelte b/frontend/src/lib/components/advanced-table.svelte new file mode 100644 index 0000000..5016d44 --- /dev/null +++ b/frontend/src/lib/components/advanced-table.svelte @@ -0,0 +1,154 @@ + + +
Items per page
+{input.error}
diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..327541c --- /dev/null +++ b/frontend/src/lib/components/ui/select/index.ts @@ -0,0 +1,34 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; + +const Root = SelectPrimitive.Root; +const Group = SelectPrimitive.Group; +const Input = SelectPrimitive.Input; +const Value = SelectPrimitive.Value; + +export { + Root, + Group, + Input, + Label, + Item, + Value, + Content, + Trigger, + Separator, + // + Root as Select, + Group as SelectGroup, + Input as SelectInput, + Label as SelectLabel, + Item as SelectItem, + Value as SelectValue, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, +}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..5ba3af6 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,39 @@ + + +