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 @@ + + +
+ onSearch((e.target as HTMLInputElement).value)} + /> + + + + {#if selectedIds} + + onAllCheck(c as boolean)} /> + + {/if} + {#each columns as column} + {#if typeof column === 'string'} + {column} + {:else} + {column.label} + {/if} + {/each} + + + + {#each items.data as item} + + {#if selectedIds} + + onCheck(c as boolean, item.id)} + /> + + {/if} + {@render rows({ item })} + + {/each} + + +
+
+

Items per page

+ onPageSizeChange(v?.value as number)} + > + + {items.pagination.itemsPerPage} + + + {#each availablePageSizes as size} + {size} + {/each} + + +
+ + + + + + {#each pages as page (page.key)} + {#if page.type !== 'ellipsis'} + + + {page.value} + + + {/if} + {/each} + + + + + +
+
diff --git a/frontend/src/lib/components/form-input.svelte b/frontend/src/lib/components/form-input.svelte index 10f4231..a1021ad 100644 --- a/frontend/src/lib/components/form-input.svelte +++ b/frontend/src/lib/components/form-input.svelte @@ -3,7 +3,7 @@ import type { FormInput } from '$lib/utils/form-util'; import type { Snippet } from 'svelte'; import type { HTMLAttributes } from 'svelte/elements'; - import { Input } from './ui/input'; + import { Input, type FormInputEvent } from './ui/input'; let { input = $bindable(), @@ -12,6 +12,7 @@ disabled = false, type = 'text', children, + onInput, ...restProps }: HTMLAttributes & { input?: FormInput; @@ -19,6 +20,7 @@ description?: string; disabled?: boolean; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox'; + onInput?: (e: FormInputEvent) => void; children?: Snippet; } = $props(); @@ -34,7 +36,7 @@ {#if children} {@render children()} {:else if input} - + onInput?.(e)} /> {/if} {#if input?.error}

{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 @@ + + + +
+ +
+
diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..12381fa --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,40 @@ + + + + + + + + + + {label || value} + + diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..58346fa --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..9ce9716 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..fdbab08 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,27 @@ + + +span]:text-muted-foreground flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...$$restProps} + let:builder + on:click + on:keydown +> + +
+ +
+
diff --git a/frontend/src/lib/services/api-service.ts b/frontend/src/lib/services/api-service.ts index 0b088d9..f9cd49a 100644 --- a/frontend/src/lib/services/api-service.ts +++ b/frontend/src/lib/services/api-service.ts @@ -13,7 +13,7 @@ abstract class APIService { if (browser) { this.api.defaults.baseURL = '/api'; } else { - this.api.defaults.baseURL = process?.env?.INTERNAL_BACKEND_URL + '/api'; + this.api.defaults.baseURL = process!.env!.INTERNAL_BACKEND_URL + '/api'; } } } diff --git a/frontend/src/lib/services/audit-log-service.ts b/frontend/src/lib/services/audit-log-service.ts index 2d1d055..61fe0cb 100644 --- a/frontend/src/lib/services/audit-log-service.ts +++ b/frontend/src/lib/services/audit-log-service.ts @@ -4,14 +4,8 @@ import APIService from './api-service'; class AuditLogService extends APIService { async list(pagination?: PaginationRequest) { - const page = pagination?.page || 1; - const limit = pagination?.limit || 10; - const res = await this.api.get('/audit-logs', { - params: { - page, - limit - } + params: pagination }); return res.data as Paginated; } diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index cfe03f2..28d64f8 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -3,7 +3,7 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type'; import APIService from './api-service'; class OidcService extends APIService { - async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) { + async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) { const res = await this.api.post('/oidc/authorize', { scope, nonce, @@ -26,14 +26,10 @@ class OidcService extends APIService { } async listClients(search?: string, pagination?: PaginationRequest) { - const page = pagination?.page || 1; - const limit = pagination?.limit || 10; - const res = await this.api.get('/oidc/clients', { params: { search, - page, - limit + ...pagination } }); return res.data as Paginated; diff --git a/frontend/src/lib/services/user-group-service.ts b/frontend/src/lib/services/user-group-service.ts new file mode 100644 index 0000000..6c752de --- /dev/null +++ b/frontend/src/lib/services/user-group-service.ts @@ -0,0 +1,43 @@ +import type { Paginated, PaginationRequest } from '$lib/types/pagination.type'; +import type { + UserGroupCreate, + UserGroupWithUserCount, + UserGroupWithUsers +} from '$lib/types/user-group.type'; +import APIService from './api-service'; + +export default class UserGroupService extends APIService { + async list(search?: string, pagination?: PaginationRequest) { + const res = await this.api.get('/user-groups', { + params: { + search, + ...pagination + } + }); + return res.data as Paginated; + } + + async get(id: string) { + const res = await this.api.get(`/user-groups/${id}`); + return res.data as UserGroupWithUsers; + } + + async create(user: UserGroupCreate) { + const res = await this.api.post('/user-groups', user); + return res.data as UserGroupWithUsers; + } + + async update(id: string, user: UserGroupCreate) { + const res = await this.api.put(`/user-groups/${id}`, user); + return res.data as UserGroupWithUsers; + } + + async remove(id: string) { + await this.api.delete(`/user-groups/${id}`); + } + + async updateUsers(id: string, userIds: string[]) { + const res = await this.api.put(`/user-groups/${id}/users`, { userIds }); + return res.data as UserGroupWithUsers; + } +} diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 3bec9be..e5c6ea7 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -4,14 +4,10 @@ import APIService from './api-service'; export default class UserService extends APIService { async list(search?: string, pagination?: PaginationRequest) { - const page = pagination?.page || 1; - const limit = pagination?.limit || 10; - const res = await this.api.get('/users', { params: { search, - page, - limit + ...pagination } }); return res.data as Paginated; diff --git a/frontend/src/lib/types/pagination.type.ts b/frontend/src/lib/types/pagination.type.ts index 7d87cb9..a463c00 100644 --- a/frontend/src/lib/types/pagination.type.ts +++ b/frontend/src/lib/types/pagination.type.ts @@ -7,6 +7,7 @@ export type PaginationResponse = { totalPages: number; totalItems: number; currentPage: number; + itemsPerPage: number; }; export type Paginated = { diff --git a/frontend/src/lib/types/user-group.type.ts b/frontend/src/lib/types/user-group.type.ts new file mode 100644 index 0000000..b86f4af --- /dev/null +++ b/frontend/src/lib/types/user-group.type.ts @@ -0,0 +1,18 @@ +import type { User } from './user.type'; + +export type UserGroup = { + id: string; + friendlyName: string; + name: string; + createdAt: string; +}; + +export type UserGroupWithUsers = UserGroup & { + users: User[]; +}; + +export type UserGroupWithUserCount = UserGroup & { + userCount: number; +}; + +export type UserGroupCreate = Pick; diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 6f2ba6e..3e94f46 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -18,6 +18,7 @@ links = [ ...links, { href: '/settings/admin/users', label: 'Users' }, + { href: '/settings/admin/user-groups', label: 'User Groups' }, { href: '/settings/admin/oidc-clients', label: 'OIDC Clients' }, { href: '/settings/admin/application-configuration', label: 'Application Configuration' } ]; diff --git a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte index 538cc42..94c5e84 100644 --- a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte @@ -8,7 +8,7 @@ id, imageClass, label, - image = $bindable(null), + image = $bindable(), imageURL, accept = 'image/png, image/jpeg, image/svg+xml', ...restProps diff --git a/frontend/src/routes/settings/admin/user-groups/+page.server.ts b/frontend/src/routes/settings/admin/user-groups/+page.server.ts new file mode 100644 index 0000000..810881e --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/+page.server.ts @@ -0,0 +1,8 @@ +import UserGroupService from '$lib/services/user-group-service'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + const userGroupService = new UserGroupService(cookies.get('access_token')); + const userGroups = await userGroupService.list(); + return userGroups; +}; diff --git a/frontend/src/routes/settings/admin/user-groups/+page.svelte b/frontend/src/routes/settings/admin/user-groups/+page.svelte new file mode 100644 index 0000000..a1956e8 --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/+page.svelte @@ -0,0 +1,73 @@ + + + + User Groups + + + + +
+
+ Create User Group + Create a new group that can be assigned to users. +
+ {#if !expandAddUserGroup} + + {:else} + + {/if} +
+
+ {#if expandAddUserGroup} +
+ + + +
+ {/if} +
+ + + + Manage User Groups + + + + + diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.server.ts b/frontend/src/routes/settings/admin/user-groups/[id]/+page.server.ts new file mode 100644 index 0000000..44c4178 --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.server.ts @@ -0,0 +1,9 @@ +import UserGroupService from '$lib/services/user-group-service'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, cookies }) => { + const userGroupService = new UserGroupService(cookies.get('access_token')); + const userGroup = await userGroupService.get(params.id); + + return { userGroup }; +}; diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte new file mode 100644 index 0000000..ddbf9b7 --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte @@ -0,0 +1,78 @@ + + + + User Group Details {userGroup.name} + + +
+ Back +
+ + + Meta data + + + + + + + + + + Users + Assign users to this group. + + + + {#await userService.list() then users} + + {/await} +
+ +
+
+
diff --git a/frontend/src/routes/settings/admin/user-groups/user-group-form.svelte b/frontend/src/routes/settings/admin/user-groups/user-group-form.svelte new file mode 100644 index 0000000..aa3d7af --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/user-group-form.svelte @@ -0,0 +1,82 @@ + + +
+
+
+ +
+
+ +
+
+
+ +
+
diff --git a/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte b/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte new file mode 100644 index 0000000..5239078 --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte @@ -0,0 +1,73 @@ + + + + {#snippet rows({ item })} + {item.friendlyName} + {item.name} + {item.userCount} + + + + + + + Edit + deleteUserGroup(item)} + >Delete + + + + {/snippet} + diff --git a/frontend/src/routes/settings/admin/user-groups/user-selection.svelte b/frontend/src/routes/settings/admin/user-groups/user-selection.svelte new file mode 100644 index 0000000..5919beb --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/user-selection.svelte @@ -0,0 +1,32 @@ + + + + {#snippet rows({ item })} + {item.firstName} {item.lastName} + {item.email} + {/snippet} + diff --git a/frontend/src/routes/settings/admin/users/+page.svelte b/frontend/src/routes/settings/admin/users/+page.svelte index 4422e21..45cf64c 100644 --- a/frontend/src/routes/settings/admin/users/+page.svelte +++ b/frontend/src/routes/settings/admin/users/+page.svelte @@ -9,7 +9,7 @@ import { LucideMinus } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { slide } from 'svelte/transition'; - import CreateUser from './user-form.svelte'; + import UserForm from './user-form.svelte'; import UserList from './user-list.svelte'; let { data } = $props(); @@ -56,7 +56,7 @@ {#if expandAddUser}
- +
{/if}