diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go
index 2bf4af0b..0a10aec1 100644
--- a/backend/internal/controller/api_key_controller.go
+++ b/backend/internal/controller/api_key_controller.go
@@ -45,15 +45,11 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
+ listRequestOptions := utils.ParseListRequestOptions(ctx)
+
userID := ctx.GetString("userID")
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = ctx.Error(err)
- return
- }
-
- apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
+ apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = ctx.Error(err)
return
diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go
index 1a4ecd9b..2126dfb3 100644
--- a/backend/internal/controller/audit_log_controller.go
+++ b/backend/internal/controller/audit_log_controller.go
@@ -41,18 +41,12 @@ type AuditLogController struct {
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
- var sortedPaginationRequest utils.SortedPaginationRequest
-
- err := c.ShouldBindQuery(&sortedPaginationRequest)
- if err != nil {
- _ = c.Error(err)
- return
- }
+ listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
// Fetch audit logs for the user
- logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
+ logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -86,26 +80,12 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
-// @Param filters[userId] query string false "Filter by user ID"
-// @Param filters[event] query string false "Filter by event type"
-// @Param filters[clientName] query string false "Filter by client name"
-// @Param filters[location] query string false "Filter by location type (external or internal)"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
+ listRequestOptions := utils.ParseListRequestOptions(c)
- var filters dto.AuditLogFilterDto
- if err := c.ShouldBindQuery(&filters); err != nil {
- _ = c.Error(err)
- return
- }
-
- logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
+ logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go
index f9b9dece..7445c0b8 100644
--- a/backend/internal/controller/oidc_controller.go
+++ b/backend/internal/controller/oidc_controller.go
@@ -403,13 +403,9 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
+ listRequestOptions := utils.ParseListRequestOptions(c)
- clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
+ clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -685,12 +681,9 @@ func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
- authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
+ listRequestOptions := utils.ParseListRequestOptions(c)
+
+ authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -741,15 +734,11 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
+ listRequestOptions := utils.ParseListRequestOptions(c)
+
userID := c.GetString("userID")
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
-
- clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
+ clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go
index 83eb6525..da99c66d 100644
--- a/backend/internal/controller/user_controller.go
+++ b/backend/internal/controller/user_controller.go
@@ -104,13 +104,9 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
+ listRequestOptions := utils.ParseListRequestOptions(c)
- users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
+ users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -574,13 +570,9 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
+ listRequestOptions := utils.ParseListRequestOptions(c)
- tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
+ tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
diff --git a/backend/internal/controller/user_group_controller.go b/backend/internal/controller/user_group_controller.go
index 3523a42a..ba05b20c 100644
--- a/backend/internal/controller/user_group_controller.go
+++ b/backend/internal/controller/user_group_controller.go
@@ -47,16 +47,10 @@ type UserGroupController struct {
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
- ctx := c.Request.Context()
-
searchTerm := c.Query("search")
- var sortedPaginationRequest utils.SortedPaginationRequest
- if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
- _ = c.Error(err)
- return
- }
+ listRequestOptions := utils.ParseListRequestOptions(c)
- groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
+ groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -70,7 +64,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
_ = c.Error(err)
return
}
- groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
+ groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
if err != nil {
_ = c.Error(err)
return
diff --git a/backend/internal/dto/audit_log_dto.go b/backend/internal/dto/audit_log_dto.go
index dceeea0c..9ef7779d 100644
--- a/backend/internal/dto/audit_log_dto.go
+++ b/backend/internal/dto/audit_log_dto.go
@@ -17,10 +17,3 @@ type AuditLogDto struct {
Username string `json:"username"`
Data map[string]string `json:"data"`
}
-
-type AuditLogFilterDto struct {
- UserID string `form:"filters[userId]"`
- Event string `form:"filters[event]"`
- ClientName string `form:"filters[clientName]"`
- Location string `form:"filters[location]"`
-}
diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go
index 0c19943a..f62f035e 100644
--- a/backend/internal/model/audit_log.go
+++ b/backend/internal/model/audit_log.go
@@ -9,7 +9,7 @@ import (
type AuditLog struct {
Base
- Event AuditLogEvent `sortable:"true"`
+ Event AuditLogEvent `sortable:"true" filterable:"true"`
IpAddress *string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
@@ -17,7 +17,7 @@ type AuditLog struct {
Username string `gorm:"-"`
Data AuditLogData
- UserID string
+ UserID string `filterable:"true"`
User User
}
diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go
index d118ee15..701d6e7a 100644
--- a/backend/internal/model/oidc.go
+++ b/backend/internal/model/oidc.go
@@ -53,8 +53,8 @@ type OidcClient struct {
LogoutCallbackURLs UrlList
ImageType *string
IsPublic bool
- PkceEnabled bool
- RequiresReauthentication bool
+ PkceEnabled bool `filterable:"true"`
+ RequiresReauthentication bool `filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go
index 76dc29b6..c596a7d7 100644
--- a/backend/internal/model/user.go
+++ b/backend/internal/model/user.go
@@ -18,10 +18,10 @@ type User struct {
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
- IsAdmin bool `sortable:"true"`
+ IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
- Disabled bool `sortable:"true"`
+ Disabled bool `sortable:"true" filterable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
index 18ec4d25..42c5ce39 100644
--- a/backend/internal/service/api_key_service.go
+++ b/backend/internal/service/api_key_service.go
@@ -25,14 +25,14 @@ func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db, emailService: emailService}
}
-func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
+func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&model.ApiKey{})
var apiKeys []model.ApiKey
- pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go
index 7855174b..c19e3560 100644
--- a/backend/internal/service/audit_log_service.go
+++ b/backend/internal/service/audit_log_service.go
@@ -6,7 +6,6 @@ import (
"log/slog"
userAgentParser "github.com/mileusna/useragent"
- "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
@@ -136,14 +135,14 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
-func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
+func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ?", userID)
- pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
return logs, pagination, err
}
@@ -152,7 +151,7 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}
-func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest, filters dto.AuditLogFilterDto) ([]model.AuditLog, utils.PaginationResponse, error) {
+func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
@@ -160,33 +159,36 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
Preload("User").
Model(&model.AuditLog{})
- if filters.UserID != "" {
- query = query.Where("user_id = ?", filters.UserID)
- }
- if filters.Event != "" {
- query = query.Where("event = ?", filters.Event)
- }
- if filters.ClientName != "" {
+ if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
dialect := s.db.Name()
switch dialect {
case "sqlite":
- query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
+ query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
case "postgres":
- query = query.Where("data->>'clientName' = ?", filters.ClientName)
+ query = query.Where("data->>'clientName' IN ?", clientName)
default:
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
}
}
- if filters.Location != "" {
- switch filters.Location {
- case "external":
- query = query.Where("country != 'Internal Network'")
- case "internal":
- query = query.Where("country = 'Internal Network'")
+
+ if locations, ok := listRequestOptions.Filters["location"]; ok {
+ mapped := make([]string, 0, len(locations))
+ for _, v := range locations {
+ if s, ok := v.(string); ok {
+ switch s {
+ case "internal":
+ mapped = append(mapped, "Internal Network")
+ case "external":
+ mapped = append(mapped, "External Network")
+ }
+ }
+ }
+ if len(mapped) > 0 {
+ query = query.Where("country IN ?", mapped)
}
}
- pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
if err != nil {
return nil, pagination, err
}
diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go
index 79edc398..a8134f5c 100644
--- a/backend/internal/service/oidc_service.go
+++ b/backend/internal/service/oidc_service.go
@@ -692,7 +692,7 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
return client, nil
}
-func (s *OidcService) ListClients(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
+func (s *OidcService) ListClients(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.
@@ -705,17 +705,17 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
}
// As allowedUserGroupsCount is not a column, we need to manually sort it
- if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
+ if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id").
- Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + sortedPaginationRequest.Sort.Direction)
+ Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction)
- response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &clients)
+ response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients)
return clients, response, err
}
- response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
+ response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
return clients, response, err
}
@@ -1350,7 +1350,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
return count, nil
}
-func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
+func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
@@ -1359,7 +1359,7 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
Where("user_id = ?", userID)
var authorizedClients []model.UserAuthorizedOidcClient
- response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &authorizedClients)
+ response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
return authorizedClients, response, err
}
@@ -1392,7 +1392,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return nil
}
-func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
+func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1439,13 +1439,13 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
- if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
+ if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
- Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction + " NULLS LAST")
+ Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
}
- response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
+ response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go
index 433c5fe7..f18a66c9 100644
--- a/backend/internal/service/user_group_service.go
+++ b/backend/internal/service/user_group_service.go
@@ -21,7 +21,7 @@ func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserG
return &UserGroupService{db: db, appConfigService: appConfigService}
}
-func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
+func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.
WithContext(ctx).
Preload("CustomClaims").
@@ -32,17 +32,14 @@ func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginati
}
// As userCount is not a column we need to manually sort it
- if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
+ if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
- Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
-
- response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
- return groups, response, err
+ Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
}
- response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
+ response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
return groups, response, err
}
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index 147fc079..18b79804 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -46,7 +46,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
}
}
-func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
+func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.WithContext(ctx).
Model(&model.User{}).
@@ -60,7 +60,7 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
searchPattern, searchPattern, searchPattern, searchPattern)
}
- pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
return users, pagination, err
}
@@ -794,11 +794,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
return user, accessToken, nil
}
-func (s *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
+func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
- pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens)
+ pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
}
diff --git a/backend/internal/utils/list_request_util.go b/backend/internal/utils/list_request_util.go
new file mode 100644
index 00000000..e52773bc
--- /dev/null
+++ b/backend/internal/utils/list_request_util.go
@@ -0,0 +1,205 @@
+package utils
+
+import (
+ "reflect"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type PaginationResponse struct {
+ TotalPages int64 `json:"totalPages"`
+ TotalItems int64 `json:"totalItems"`
+ CurrentPage int `json:"currentPage"`
+ ItemsPerPage int `json:"itemsPerPage"`
+}
+
+type ListRequestOptions struct {
+ Pagination struct {
+ Page int `form:"pagination[page]"`
+ Limit int `form:"pagination[limit]"`
+ } `form:"pagination"`
+ Sort struct {
+ Column string `form:"sort[column]"`
+ Direction string `form:"sort[direction]"`
+ } `form:"sort"`
+ Filters map[string][]any
+}
+
+type FieldMeta struct {
+ ColumnName string
+ IsSortable bool
+ IsFilterable bool
+}
+
+func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) {
+ if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil {
+ return listRequestOptions
+ }
+
+ listRequestOptions.Filters = parseNestedFilters(ctx)
+ return listRequestOptions
+}
+
+func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result interface{}) (PaginationResponse, error) {
+ meta := extractModelMetadata(result)
+
+ query = applyFilters(params.Filters, query, meta)
+ query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta)
+
+ return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
+}
+
+func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
+ if page < 1 {
+ page = 1
+ }
+
+ if pageSize < 1 {
+ pageSize = 20
+ } else if pageSize > 100 {
+ pageSize = 100
+ }
+
+ var totalItems int64
+ if err := query.Count(&totalItems).Error; err != nil {
+ return PaginationResponse{}, err
+ }
+
+ totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
+ if totalItems == 0 {
+ totalPages = 1
+ }
+
+ if int64(page) > totalPages {
+ page = int(totalPages)
+ }
+
+ offset := (page - 1) * pageSize
+
+ if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
+ return PaginationResponse{}, err
+ }
+
+ return PaginationResponse{
+ TotalPages: totalPages,
+ TotalItems: totalItems,
+ CurrentPage: page,
+ ItemsPerPage: pageSize,
+ }, nil
+}
+
+func NormalizeSortDirection(direction string) string {
+ d := strings.ToLower(strings.TrimSpace(direction))
+ if d != "asc" && d != "desc" {
+ return "asc"
+ }
+ return d
+}
+
+func IsValidSortDirection(direction string) bool {
+ d := strings.ToLower(strings.TrimSpace(direction))
+ return d == "asc" || d == "desc"
+}
+
+// parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2
+func parseNestedFilters(ctx *gin.Context) map[string][]any {
+ result := make(map[string][]any)
+ query := ctx.Request.URL.Query()
+
+ for key, values := range query {
+ if !strings.HasPrefix(key, "filters[") {
+ continue
+ }
+
+ // Keys can be "filters[field]" or "filters[field][0]"
+ raw := strings.TrimPrefix(key, "filters[")
+ // Take everything up to the first closing bracket
+ if idx := strings.IndexByte(raw, ']'); idx != -1 {
+ field := raw[:idx]
+ for _, v := range values {
+ result[field] = append(result[field], ConvertStringToType(v))
+ }
+ }
+ }
+
+ return result
+}
+
+// applyFilters applies filtering to the GORM query based on the provided filters
+func applyFilters(filters map[string][]any, query *gorm.DB, meta map[string]FieldMeta) *gorm.DB {
+ for key, values := range filters {
+ if key == "" || len(values) == 0 {
+ continue
+ }
+
+ fieldName := CapitalizeFirstLetter(key)
+ fieldMeta, ok := meta[fieldName]
+ if !ok || !fieldMeta.IsFilterable {
+ continue
+ }
+
+ query = query.Where(fieldMeta.ColumnName+" IN ?", values)
+ }
+ return query
+}
+
+// applySorting applies sorting to the GORM query based on the provided column and direction
+func applySorting(sortColumn string, sortDirection string, query *gorm.DB, meta map[string]FieldMeta) *gorm.DB {
+ fieldName := CapitalizeFirstLetter(sortColumn)
+ fieldMeta, ok := meta[fieldName]
+ if !ok || !fieldMeta.IsSortable {
+ return query
+ }
+
+ sortDirection = NormalizeSortDirection(sortDirection)
+
+ query = query.Clauses(clause.OrderBy{
+ Columns: []clause.OrderByColumn{
+ {Column: clause.Column{Name: fieldMeta.ColumnName}, Desc: sortDirection == "desc"},
+ },
+ })
+ return query
+}
+
+// extractModelMetadata extracts FieldMeta from the model struct using reflection
+func extractModelMetadata(model interface{}) map[string]FieldMeta {
+ meta := make(map[string]FieldMeta)
+
+ // Unwrap pointers and slices to get the element struct type
+ t := reflect.TypeOf(model)
+ for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
+ t = t.Elem()
+ if t == nil {
+ return meta
+ }
+ }
+
+ // recursive parser that merges fields from embedded structs
+ var parseStruct func(reflect.Type)
+ parseStruct = func(st reflect.Type) {
+ for i := 0; i < st.NumField(); i++ {
+ field := st.Field(i)
+ ft := field.Type
+
+ // If the field is an embedded/anonymous struct, recurse into it
+ if field.Anonymous && ft.Kind() == reflect.Struct {
+ parseStruct(ft)
+ continue
+ }
+
+ // Normal field: record metadata
+ name := field.Name
+ meta[name] = FieldMeta{
+ ColumnName: CamelCaseToSnakeCase(name),
+ IsSortable: field.Tag.Get("sortable") == "true",
+ IsFilterable: field.Tag.Get("filterable") == "true",
+ }
+ }
+ }
+
+ parseStruct(t)
+ return meta
+}
diff --git a/backend/internal/utils/paging_util.go b/backend/internal/utils/paging_util.go
deleted file mode 100644
index d69e1bca..00000000
--- a/backend/internal/utils/paging_util.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package utils
-
-import (
- "reflect"
- "strconv"
- "strings"
-
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
-)
-
-type PaginationResponse struct {
- TotalPages int64 `json:"totalPages"`
- TotalItems int64 `json:"totalItems"`
- CurrentPage int `json:"currentPage"`
- ItemsPerPage int `json:"itemsPerPage"`
-}
-
-type SortedPaginationRequest struct {
- Pagination struct {
- Page int `form:"pagination[page]"`
- Limit int `form:"pagination[limit]"`
- } `form:"pagination"`
- Sort struct {
- Column string `form:"sort[column]"`
- Direction string `form:"sort[direction]"`
- } `form:"sort"`
-}
-
-func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gorm.DB, result interface{}) (PaginationResponse, error) {
- pagination := sortedPaginationRequest.Pagination
- sort := sortedPaginationRequest.Sort
-
- capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
-
- sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
- isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
-
- sort.Direction = NormalizeSortDirection(sort.Direction)
-
- if sortFieldFound && isSortable {
- columnName := CamelCaseToSnakeCase(sort.Column)
- query = query.Clauses(clause.OrderBy{
- Columns: []clause.OrderByColumn{
- {Column: clause.Column{Name: columnName}, Desc: sort.Direction == "desc"},
- },
- })
- }
-
- return Paginate(pagination.Page, pagination.Limit, query, result)
-}
-
-func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
- if page < 1 {
- page = 1
- }
-
- if pageSize < 1 {
- pageSize = 20
- } else if pageSize > 100 {
- pageSize = 100
- }
-
- offset := (page - 1) * pageSize
-
- var totalItems int64
- if err := query.Count(&totalItems).Error; err != nil {
- return PaginationResponse{}, err
- }
-
- if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
- return PaginationResponse{}, err
- }
-
- totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
- if totalItems == 0 {
- totalPages = 1
- }
-
- return PaginationResponse{
- TotalPages: totalPages,
- TotalItems: totalItems,
- CurrentPage: page,
- ItemsPerPage: pageSize,
- }, nil
-}
-
-func NormalizeSortDirection(direction string) string {
- d := strings.ToLower(strings.TrimSpace(direction))
- if d != "asc" && d != "desc" {
- return "asc"
- }
- return d
-}
-
-func IsValidSortDirection(direction string) bool {
- d := strings.ToLower(strings.TrimSpace(direction))
- return d == "asc" || d == "desc"
-}
diff --git a/backend/internal/utils/string_util.go b/backend/internal/utils/string_util.go
index b68fc227..1f7ee7a1 100644
--- a/backend/internal/utils/string_util.go
+++ b/backend/internal/utils/string_util.go
@@ -81,26 +81,21 @@ func CapitalizeFirstLetter(str string) string {
return result.String()
}
-func CamelCaseToSnakeCase(str string) string {
- result := strings.Builder{}
- result.Grow(int(float32(len(str)) * 1.1))
- for i, r := range str {
- if unicode.IsUpper(r) && i > 0 {
- result.WriteByte('_')
- }
- result.WriteRune(unicode.ToLower(r))
- }
- return result.String()
+var (
+ reAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`) // ABCd -> AB_Cd
+ reLowerToUpper = regexp.MustCompile(`([a-z0-9])([A-Z])`) // aB -> a_B
+)
+
+func CamelCaseToSnakeCase(s string) string {
+ s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
+ s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
+ return strings.ToLower(s)
}
-var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
-
func CamelCaseToScreamingSnakeCase(s string) string {
- // Insert underscores before uppercase letters (except the first one)
- snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
-
- // Convert to uppercase
- return strings.ToUpper(snake)
+ s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
+ s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
+ return strings.ToUpper(s)
}
// GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode
diff --git a/backend/internal/utils/string_util_test.go b/backend/internal/utils/string_util_test.go
index a22dd1f2..d6faab04 100644
--- a/backend/internal/utils/string_util_test.go
+++ b/backend/internal/utils/string_util_test.go
@@ -86,9 +86,9 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
{"simple camelCase", "camelCase", "camel_case"},
{"PascalCase", "PascalCase", "pascal_case"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
- {"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"},
+ {"consecutive uppercase", "HTTPRequest", "http_request"},
{"single lowercase word", "word", "word"},
- {"single uppercase word", "WORD", "w_o_r_d"},
+ {"single uppercase word", "WORD", "word"},
{"with numbers", "camel123Case", "camel123_case"},
{"with numbers in middle", "model2Name", "model2_name"},
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
@@ -104,6 +104,34 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
}
}
+func TestCamelCaseToScreamingSnakeCase(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"empty string", "", ""},
+ {"simple camelCase", "camelCase", "CAMEL_CASE"},
+ {"PascalCase", "PascalCase", "PASCAL_CASE"},
+ {"multipleWordsInCamelCase", "multipleWordsInCamelCase", "MULTIPLE_WORDS_IN_CAMEL_CASE"},
+ {"consecutive uppercase", "HTTPRequest", "HTTP_REQUEST"},
+ {"single lowercase word", "word", "WORD"},
+ {"single uppercase word", "WORD", "WORD"},
+ {"with numbers", "camel123Case", "CAMEL123_CASE"},
+ {"with numbers in middle", "model2Name", "MODEL2_NAME"},
+ {"mixed case", "iPhone6sPlus", "I_PHONE6S_PLUS"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := CamelCaseToScreamingSnakeCase(tt.input)
+ if result != tt.expected {
+ t.Errorf("CamelCaseToScreamingSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
func TestGetFirstCharacter(t *testing.T) {
tests := []struct {
name string
diff --git a/backend/internal/utils/type_util.go b/backend/internal/utils/type_util.go
new file mode 100644
index 00000000..6b678a15
--- /dev/null
+++ b/backend/internal/utils/type_util.go
@@ -0,0 +1,35 @@
+package utils
+
+import (
+ "strconv"
+ "strings"
+)
+
+// ConvertStringToType attempts to convert a string to bool, int, or float.
+func ConvertStringToType(value string) any {
+ v := strings.TrimSpace(value)
+ if v == "" {
+ return v
+ }
+
+ // Try bool
+ if v == "true" {
+ return true
+ }
+ if v == "false" {
+ return false
+ }
+
+ // Try int
+ if i, err := strconv.Atoi(v); err == nil {
+ return i
+ }
+
+ // Try float
+ if f, err := strconv.ParseFloat(v, 64); err == nil {
+ return f
+ }
+
+ // Default: string
+ return v
+}
diff --git a/backend/internal/utils/type_util_test.go b/backend/internal/utils/type_util_test.go
new file mode 100644
index 00000000..a7b38746
--- /dev/null
+++ b/backend/internal/utils/type_util_test.go
@@ -0,0 +1,37 @@
+package utils
+
+import (
+ "testing"
+)
+
+func TestConvertStringToType(t *testing.T) {
+ tests := []struct {
+ input string
+ expected any
+ }{
+ {"true", true},
+ {"false", false},
+ {" true ", true},
+ {" false ", false},
+ {"42", 42},
+ {" 42 ", 42},
+ {"3.14", 3.14},
+ {" 3.14 ", 3.14},
+ {"hello", "hello"},
+ {" hello ", "hello"},
+ {"", ""},
+ {" ", ""},
+ }
+
+ for _, tt := range tests {
+ result := ConvertStringToType(tt.input)
+ if result != tt.expected {
+ if f, ok := tt.expected.(float64); ok {
+ if rf, ok := result.(float64); ok && rf == f {
+ continue
+ }
+ }
+ t.Errorf("ConvertStringToType(%q) = %#v (type %T), want %#v (type %T)", tt.input, result, result, tt.expected, tt.expected)
+ }
+ }
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 2e35f3bf..a0dcfb86 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -455,5 +455,11 @@
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at Selfh.st Icons or Dashboard Icons.",
"invalid_url": "Invalid URL",
"require_user_email": "Require Email Address",
- "require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address."
+ "require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.",
+ "view": "View",
+ "toggle_columns": "Toggle columns",
+ "locale": "Locale",
+ "ldap_id" : "LDAP ID",
+ "reauthentication": "Re-authentication",
+ "clear_filters" : "Clear Filters"
}
diff --git a/frontend/src/lib/components/advanced-table.svelte b/frontend/src/lib/components/advanced-table.svelte
deleted file mode 100644
index 4efa44c3..00000000
--- a/frontend/src/lib/components/advanced-table.svelte
+++ /dev/null
@@ -1,217 +0,0 @@
-
-
-{#if !withoutSearch}
- onSearch((e.currentTarget as HTMLInputElement).value)}
- />
-{/if}
-
-{#if items.data.length === 0 && searchValue === ''}
-
-
-
{m.no_items_found()}
-
-{:else}
-
-
-
- {#if selectedIds}
-
- onAllCheck(c as boolean)}
- />
-
- {/if}
- {#each columns as column}
-
- {#if column.sortColumn}
-
- {:else}
- {column.label}
- {/if}
-
- {/each}
-
-
-
- {#each items.data as item}
-
- {#if selectedIds}
-
- onCheck(c, item.id)}
- />
-
- {/if}
- {@render rows({ item })}
-
- {/each}
-
-
-
-
-
-
{m.items_per_page()}
-
onPageSizeChange(Number(v))}
- >
-
- {items.pagination.itemsPerPage}
-
-
- {#each availablePageSizes as size}
- {size}
- {/each}
-
-
-
-
- {#snippet children({ pages })}
-
-
-
-
- {#each pages as page (page.key)}
- {#if page.type !== 'ellipsis' && page.value != 0}
-
-
- {page.value}
-
-
- {/if}
- {/each}
-
-
-
-
- {/snippet}
-
-
-{/if}
diff --git a/frontend/src/lib/components/audit-log-list.svelte b/frontend/src/lib/components/audit-log-list.svelte
index 22e7e55d..4fffcc27 100644
--- a/frontend/src/lib/components/audit-log-list.svelte
+++ b/frontend/src/lib/components/audit-log-list.svelte
@@ -1,69 +1,111 @@
+{#snippet EventCell({ item }: { item: AuditLog })}
+
+ {translateAuditLogEvent(item.event)}
+
+{/snippet}
+
+ id="audit-log-list-{isAdmin ? 'admin' : 'user'}"
+ bind:this={tableRef}
+ fetchCallback={async (options) =>
isAdmin
- ? (auditLogs = await auditLogService.listAllLogs(options))
- : (auditLogs = await auditLogService.list(options))}
- columns={[
- { label: m.time(), sortColumn: 'createdAt' },
- ...(isAdmin ? [{ label: 'Username' }] : []),
- { label: m.event(), sortColumn: 'event' },
- { label: m.approximate_location(), sortColumn: 'city' },
- { label: m.ip_address(), sortColumn: 'ipAddress' },
- { label: m.device(), sortColumn: 'device' },
- { label: m.client() }
- ]}
+ ? await auditLogService.listAllLogs({
+ ...options,
+ filters: wrapFilters(filters)
+ })
+ : await auditLogService.list(options)}
+ defaultSort={{ column: 'createdAt', direction: 'desc' }}
withoutSearch
->
- {#snippet rows({ item })}
- {new Date(item.createdAt).toLocaleString()}
- {#if isAdmin}
-
- {#if item.username}
- {item.username}
- {:else}
- Unknown User
- {/if}
-
- {/if}
-
- {translateAuditLogEvent(item.event)}
-
-
- {#if item.city && item.country}
- {item.city}, {item.country}
- {:else if item.country}
- {item.country}
- {:else}
- {m.unknown()}
- {/if}
-
- {item.ipAddress}
- {item.device}
- {item.data.clientName}
- {/snippet}
-
+ {columns}
+/>
diff --git a/frontend/src/lib/components/image-box.svelte b/frontend/src/lib/components/image-box.svelte
index f86696a9..b334c18e 100644
--- a/frontend/src/lib/components/image-box.svelte
+++ b/frontend/src/lib/components/image-box.svelte
@@ -12,14 +12,10 @@
});
-
+
{#if error}
-
+
{:else}
-
![]()
(error = true)}
- />
+
![]()
(error = true)} />
{/if}
diff --git a/frontend/src/lib/components/signup/signup-token-list-modal.svelte b/frontend/src/lib/components/signup/signup-token-list-modal.svelte
index cadd2970..c1581694 100644
--- a/frontend/src/lib/components/signup/signup-token-list-modal.svelte
+++ b/frontend/src/lib/components/signup/signup-token-list-modal.svelte
@@ -1,33 +1,29 @@
+{#snippet TokenCell({ item }: { item: SignupTokenDto })}
+
+ {item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
+
+{/snippet}
+
+{#snippet StatusCell({ item }: { item: SignupTokenDto })}
+ {@const status = getTokenStatus(item)}
+ {@const statusBadge = getStatusBadge(status)}
+
+ {statusBadge.text}
+
+{/snippet}
+
+{#snippet UsageCell({ item }: { item: SignupTokenDto })}
+
+ {item.usageCount}
+ {m.of()}
+ {item.usageLimit}
+
+{/snippet}
+
@@ -111,70 +164,13 @@
{
- const result = await userService.listSignupTokens(options);
- signupTokens = result;
- return result;
- }}
- columns={[
- { label: m.token() },
- { label: m.status() },
- { label: m.usage(), sortColumn: 'usageCount' },
- { label: m.expires(), sortColumn: 'expiresAt' },
- { label: m.created(), sortColumn: 'createdAt' },
- { label: m.actions(), hidden: true }
- ]}
- >
- {#snippet rows({ item })}
-
- {item.token.substring(0, 2)}...{item.token.substring(item.token.length - 4)}
-
-
- {@const status = getTokenStatus(item)}
- {@const statusBadge = getStatusBadge(status)}
-
- {statusBadge.text}
-
-
-
-
- {`${item.usageCount} ${m.of()} ${item.usageLimit}`}
-
-
-
-
- {formatDate(item.expiresAt)}
-
-
-
- {formatDate(item.createdAt)}
-
-
-
-
-
- {m.toggle_menu()}
-
-
- copySignupLink(item)}>
-
- {m.copy()}
-
- deleteToken(item)}
- >
-
- {m.delete()}
-
-
-
-
- {/snippet}
-
+ fetchCallback={userService.listSignupTokens}
+ bind:this={tableRef}
+ {columns}
+ {actions}
+ />
diff --git a/frontend/src/routes/settings/admin/user-groups/+page.ts b/frontend/src/routes/settings/admin/user-groups/+page.ts
deleted file mode 100644
index 7be7a28b..00000000
--- a/frontend/src/routes/settings/admin/user-groups/+page.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import UserGroupService from '$lib/services/user-group-service';
-import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
-import type { PageLoad } from './$types';
-
-export const load: PageLoad = async () => {
- const userGroupService = new UserGroupService();
-
- const userGroupsRequestOptions: SearchPaginationSortRequest = {
- sort: {
- column: 'friendlyName',
- direction: 'asc'
- }
- };
-
- const userGroups = await userGroupService.list(userGroupsRequestOptions);
- return { userGroups, userGroupsRequestOptions };
-};
diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte
index 3e609e4c..bb44ff1a 100644
--- a/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte
+++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.svelte
@@ -4,6 +4,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
+ import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
@@ -11,9 +12,9 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
+ import { backNavigate } from '../../users/navigate-back-util';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
- import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroup = $state({
@@ -23,6 +24,7 @@
const userGroupService = new UserGroupService();
const customClaimService = new CustomClaimService();
+ const backNavigation = backNavigate('/settings/admin/user-groups');
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true;
@@ -61,8 +63,8 @@
-
{m.back()} {m.back()}
{#if !!userGroup.ldapId}
{m.ldap()}
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
index 6569a5dc..cb164c96 100644
--- a/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte
+++ b/frontend/src/routes/settings/admin/user-groups/user-group-list.svelte
@@ -1,29 +1,58 @@
+{#snippet SourceCell({ item }: { item: UserGroupWithUserCount })}
+
+ {item.ldapId ? m.ldap() : m.local()}
+
+{/snippet}
+
(userGroups = await userGroupService.list(o))}
- {requestOptions}
- columns={[
- { label: m.friendly_name(), sortColumn: 'friendlyName' },
- { label: m.name(), sortColumn: 'name' },
- { label: m.user_count(), sortColumn: 'userCount' },
- ...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
- { label: m.actions(), hidden: true }
- ]}
->
- {#snippet rows({ item })}
- {item.friendlyName}
- {item.name}
- {item.userCount}
- {#if $appConfigStore.ldapEnabled}
-
- {item.ldapId ? m.ldap() : m.local()}
-
- {/if}
-
-
-
-
- {m.toggle_menu()}
-
-
- goto(`/settings/admin/user-groups/${item.id}`)}
- > {m.edit()}
- {#if !item.ldapId || !$appConfigStore.ldapEnabled}
- deleteUserGroup(item)}
- >{m.delete()}
- {/if}
-
-
-
- {/snippet}
-
+ id="user-group-list"
+ bind:this={tableRef}
+ fetchCallback={userGroupService.list}
+ defaultSort={{ column: 'friendlyName', direction: 'asc' }}
+ {columns}
+ {actions}
+/>
diff --git a/frontend/src/routes/settings/admin/user-groups/user-selection.svelte b/frontend/src/routes/settings/admin/user-groups/user-selection.svelte
index 96c741ef..33db3eb3 100644
--- a/frontend/src/routes/settings/admin/user-groups/user-selection.svelte
+++ b/frontend/src/routes/settings/admin/user-groups/user-selection.svelte
@@ -1,11 +1,12 @@
-{#if users}
-
(users = await userService.list(o))}
- {requestOptions}
- columns={[
- { label: m.name(), sortColumn: 'firstName' },
- { label: m.email(), sortColumn: 'email' }
- ]}
- bind:selectedIds={selectedUserIds}
- {selectionDisabled}
- >
- {#snippet rows({ item })}
- {item.displayName}
- {item.email}
- {/snippet}
-
-{/if}
+{#snippet ProfilePictureCell({ item }: { item: User })}
+
+
+
+{/snippet}
+
+{#snippet StatusCell({ item }: { item: User })}
+
+ {item.disabled ? m.disabled() : m.enabled()}
+
+{/snippet}
+
+
diff --git a/frontend/src/routes/settings/admin/users/+page.svelte b/frontend/src/routes/settings/admin/users/+page.svelte
index 45674251..52b06a1d 100644
--- a/frontend/src/routes/settings/admin/users/+page.svelte
+++ b/frontend/src/routes/settings/admin/users/+page.svelte
@@ -15,17 +15,12 @@
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
- let { data } = $props();
- let users = $state(data.users);
- let usersRequestOptions = $state(data.usersRequestOptions);
- let signupTokens = $state(data.signupTokens);
- let signupTokensRequestOptions = $state(data.signupTokensRequestOptions);
-
let selectedCreateOptions = $state(m.add_user());
let expandAddUser = $state(false);
let signupTokenModalOpen = $state(false);
let signupTokenListModalOpen = $state(false);
+ let userListRef: UserList;
const userService = new UserService();
async function createUser(user: UserCreate) {
@@ -38,13 +33,9 @@
success = false;
});
- users = await userService.list(usersRequestOptions);
+ await userListRef.refresh();
return success;
}
-
- async function refreshSignupTokens() {
- signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
- }
@@ -117,15 +108,10 @@
-
+
-
-
+
+
diff --git a/frontend/src/routes/settings/admin/users/+page.ts b/frontend/src/routes/settings/admin/users/+page.ts
deleted file mode 100644
index 18340dda..00000000
--- a/frontend/src/routes/settings/admin/users/+page.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import UserService from '$lib/services/user-service';
-import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
-import type { PageLoad } from './$types';
-
-export const load: PageLoad = async () => {
- const userService = new UserService();
-
- const usersRequestOptions: SearchPaginationSortRequest = {
- sort: {
- column: 'firstName',
- direction: 'asc'
- }
- };
-
- const signupTokensRequestOptions: SearchPaginationSortRequest = {
- sort: {
- column: 'createdAt',
- direction: 'desc'
- }
- };
-
- const [users, signupTokens] = await Promise.all([
- userService.list(usersRequestOptions),
- userService.listSignupTokens(signupTokensRequestOptions)
- ]);
-
- return {
- users,
- usersRequestOptions,
- signupTokens,
- signupTokensRequestOptions
- };
-};
diff --git a/frontend/src/routes/settings/admin/users/[id]/+page.svelte b/frontend/src/routes/settings/admin/users/[id]/+page.svelte
index f8f3a723..3a13b971 100644
--- a/frontend/src/routes/settings/admin/users/[id]/+page.svelte
+++ b/frontend/src/routes/settings/admin/users/[id]/+page.svelte
@@ -14,6 +14,7 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
+ import { backNavigate } from '../navigate-back-util';
import UserForm from '../user-form.svelte';
let { data } = $props();
@@ -24,6 +25,7 @@
const userService = new UserService();
const customClaimService = new CustomClaimService();
+ const backNavigation = backNavigate('/settings/admin/users');
async function updateUserGroups(userIds: string[]) {
await userService
@@ -81,8 +83,8 @@
-
{m.back()} backNavigation.go()}
+ >
{m.back()}
{#if !!user.ldapId}
{m.ldap()}
diff --git a/frontend/src/routes/settings/admin/users/navigate-back-util.ts b/frontend/src/routes/settings/admin/users/navigate-back-util.ts
new file mode 100644
index 00000000..ef6b1322
--- /dev/null
+++ b/frontend/src/routes/settings/admin/users/navigate-back-util.ts
@@ -0,0 +1,20 @@
+import { afterNavigate, goto } from '$app/navigation';
+
+export const backNavigate = (defaultRoute: string) => {
+ let previousUrl: URL | undefined;
+ afterNavigate((e) => {
+ if (e.from) {
+ previousUrl = e.from.url;
+ }
+ });
+
+ return {
+ go: () => {
+ if (previousUrl && previousUrl.pathname === defaultRoute) {
+ window.history.back();
+ } else {
+ goto(defaultRoute);
+ }
+ }
+ };
+};
diff --git a/frontend/src/routes/settings/admin/users/user-list.svelte b/frontend/src/routes/settings/admin/users/user-list.svelte
index 4bbe94b0..aa942940 100644
--- a/frontend/src/routes/settings/admin/users/user-list.svelte
+++ b/frontend/src/routes/settings/admin/users/user-list.svelte
@@ -1,18 +1,20 @@
+{#snippet ProfilePictureCell({ item }: { item: User })}
+
+
+
+{/snippet}
+
+{#snippet StatusCell({ item }: { item: User })}
+
+ {item.disabled ? m.disabled() : m.enabled()}
+
+{/snippet}
+
+{#snippet SourceCell({ item }: { item: User })}
+
+ {item.ldapId ? m.ldap() : m.local()}
+
+{/snippet}
+
(users = await userService.list(options))}
- columns={[
- { label: m.first_name(), sortColumn: 'firstName' },
- { label: m.last_name(), sortColumn: 'lastName' },
- { label: m.display_name(), sortColumn: 'displayName' },
- { label: m.email(), sortColumn: 'email' },
- { label: m.username(), sortColumn: 'username' },
- { label: m.role(), sortColumn: 'isAdmin' },
- { label: m.status(), sortColumn: 'disabled' },
- ...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
- { label: m.actions(), hidden: true }
- ]}
->
- {#snippet rows({ item })}
- {item.firstName}
- {item.lastName}
- {item.displayName}
- {item.email}
- {item.username}
-
- {item.isAdmin ? m.admin() : m.user()}
-
-
-
- {item.disabled ? m.disabled() : m.enabled()}
-
-
- {#if $appConfigStore.ldapEnabled}
-
- {item.ldapId ? m.ldap() : m.local()}
-
- {/if}
-
-
-
-
- {m.toggle_menu()}
-
-
- (userIdToCreateOneTimeLink = item.id)}
- >{m.login_code()}
- goto(`/settings/admin/users/${item.id}`)}
- > {m.edit()}
- {#if !item.ldapId || !$appConfigStore.ldapEnabled}
- {#if item.disabled}
- enableUser(item)}
- >{m.enable()}
- {:else}
- disableUser(item)}
- >{m.disable()}
- {/if}
- {/if}
- {#if !item.ldapId || (item.ldapId && item.disabled)}
- deleteUser(item)}
- >{m.delete()}
- {/if}
-
-
-
- {/snippet}
-
+ id="user-list"
+ bind:this={tableRef}
+ fetchCallback={userService.list}
+ {actions}
+ {columns}
+/>
diff --git a/frontend/src/routes/settings/apps/+page.svelte b/frontend/src/routes/settings/apps/+page.svelte
index 8da5d47a..41bd96c9 100644
--- a/frontend/src/routes/settings/apps/+page.svelte
+++ b/frontend/src/routes/settings/apps/+page.svelte
@@ -3,8 +3,8 @@
import * as Pagination from '$lib/components/ui/pagination';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
+ import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
- import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LayoutDashboard } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
@@ -12,11 +12,11 @@
let { data } = $props();
let clients: Paginated
= $state(data.clients);
- let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
+ let requestOptions: ListRequestOptions = $state(data.appRequestOptions);
const oidcService = new OIDCService();
- async function onRefresh(options: SearchPaginationSortRequest) {
+ async function onRefresh(options: ListRequestOptions) {
clients = await oidcService.listOwnAccessibleClients(options);
}
@@ -83,6 +83,10 @@
{#each clients.data as client}
{/each}
+
+ {#if clients.data.length == 2}
+
+ {/if}
{#if clients.pagination.totalPages > 1}
diff --git a/frontend/src/routes/settings/apps/+page.ts b/frontend/src/routes/settings/apps/+page.ts
index 85a61ffd..c3253326 100644
--- a/frontend/src/routes/settings/apps/+page.ts
+++ b/frontend/src/routes/settings/apps/+page.ts
@@ -1,11 +1,11 @@
import OIDCService from '$lib/services/oidc-service';
-import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
+import type { ListRequestOptions } from '$lib/types/list-request.type';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const oidcService = new OIDCService();
- const appRequestOptions: SearchPaginationSortRequest = {
+ const appRequestOptions: ListRequestOptions = {
pagination: {
page: 1,
limit: 20
diff --git a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte
index e647810f..96de4ad1 100644
--- a/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte
+++ b/frontend/src/routes/settings/apps/authorized-oidc-client-card.svelte
@@ -38,7 +38,7 @@
@@ -19,7 +16,7 @@
{/if}
-
+
@@ -28,7 +25,7 @@
{m.see_your_account_activities_from_the_last_3_months()}
-
+
diff --git a/frontend/src/routes/settings/audit-log/+page.ts b/frontend/src/routes/settings/audit-log/+page.ts
deleted file mode 100644
index 17aca050..00000000
--- a/frontend/src/routes/settings/audit-log/+page.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import AuditLogService from '$lib/services/audit-log-service';
-import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
-import type { PageLoad } from './$types';
-
-export const load: PageLoad = async () => {
- const auditLogService = new AuditLogService();
- const auditLogsRequestOptions: SearchPaginationSortRequest = {
- sort: {
- column: 'createdAt',
- direction: 'desc'
- }
- };
- const auditLogs = await auditLogService.list(auditLogsRequestOptions);
- return { auditLogs, auditLogsRequestOptions };
-};
diff --git a/frontend/src/routes/settings/audit-log/global/+page.svelte b/frontend/src/routes/settings/audit-log/global/+page.svelte
index 26fb4c9d..da104bfa 100644
--- a/frontend/src/routes/settings/audit-log/global/+page.svelte
+++ b/frontend/src/routes/settings/audit-log/global/+page.svelte
@@ -6,15 +6,11 @@
import { m } from '$lib/paraglide/messages';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLogFilter } from '$lib/types/audit-log.type';
+ import { eventTypes as eventTranslations } from '$lib/utils/audit-log-translator';
import AuditLogSwitcher from '../audit-log-switcher.svelte';
- import {eventTypes as eventTranslations} from "$lib/utils/audit-log-translator";
-
- let { data } = $props();
const auditLogService = new AuditLogService();
-
- let auditLogs = $state(data.auditLogs);
- let requestOptions = $state(data.requestOptions);
+ let auditLogListRef: AuditLogList;
let filters: AuditLogFilter = $state({
userId: '',
@@ -29,10 +25,6 @@
});
const eventTypes = $state(eventTranslations);
-
- $effect(() => {
- auditLogService.listAllLogs(requestOptions, filters).then((response) => (auditLogs = response));
- });
@@ -124,7 +116,6 @@
{/await}
-
-
+
diff --git a/frontend/src/routes/settings/audit-log/global/+page.ts b/frontend/src/routes/settings/audit-log/global/+page.ts
deleted file mode 100644
index 955c4e88..00000000
--- a/frontend/src/routes/settings/audit-log/global/+page.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import AuditLogService from '$lib/services/audit-log-service';
-import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
-import type { PageLoad } from './$types';
-
-export const load: PageLoad = async () => {
- const auditLogService = new AuditLogService();
-
- const requestOptions: SearchPaginationSortRequest = {
- sort: {
- column: 'createdAt',
- direction: 'desc'
- }
- };
-
- const auditLogs = await auditLogService.listAllLogs(requestOptions);
-
- return {
- auditLogs,
- requestOptions
- };
-};
diff --git a/tests/setup/docker-compose.yml b/tests/setup/docker-compose.yml
index 635b975c..03db178f 100644
--- a/tests/setup/docker-compose.yml
+++ b/tests/setup/docker-compose.yml
@@ -18,4 +18,4 @@ services:
args:
- BUILD_TAGS=e2etest
context: ../..
- dockerfile: Dockerfile
+ dockerfile: docker/Dockerfile
diff --git a/tests/specs/api-key.spec.ts b/tests/specs/api-key.spec.ts
index df2c4b43..4c9e1acc 100644
--- a/tests/specs/api-key.spec.ts
+++ b/tests/specs/api-key.spec.ts
@@ -56,11 +56,13 @@ test.describe('API Key Management', () => {
await page
.getByRole('row', { name: apiKey.name })
- .getByRole('button', { name: 'Revoke' })
+ .getByRole('button', { name: 'Toggle menu' })
.click();
- await page.getByText('Revoke', { exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Revoke' }).click();
+ await page.getByRole('button', { name: 'Revoke' }).click();
+
// Verify success message
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');
diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts
index 759432c7..c44c4e22 100644
--- a/tests/specs/oidc-client-settings.spec.ts
+++ b/tests/specs/oidc-client-settings.spec.ts
@@ -97,8 +97,14 @@ test('Delete OIDC client', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto('/settings/admin/oidc-clients');
- await page.getByRole('row', { name: oidcClient.name }).getByLabel('Delete').click();
- await page.getByText('Delete', { exact: true }).click();
+ await page
+ .getByRole('row', { name: oidcClient.name })
+ .getByRole('button', { name: 'Toggle menu' })
+ .click();
+
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+
+ await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'OIDC client deleted successfully'
diff --git a/tests/specs/user-group.spec.ts b/tests/specs/user-group.spec.ts
index fc3d3bbc..7deb39b0 100644
--- a/tests/specs/user-group.spec.ts
+++ b/tests/specs/user-group.spec.ts
@@ -45,8 +45,8 @@ test('Update user group users', async ({ page }) => {
const group = userGroups.designers;
await page.goto(`/settings/admin/user-groups/${group.id}`);
- await page.getByRole('row', { name: users.tim.email }).getByRole('checkbox').click();
- await page.getByRole('row', { name: users.craig.email }).getByRole('checkbox').click();
+ await page.getByRole('row', { name: users.tim.username }).getByRole('checkbox').click();
+ await page.getByRole('row', { name: users.craig.username }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
@@ -55,10 +55,10 @@ test('Update user group users', async ({ page }) => {
await page.reload();
await expect(
- page.getByRole('row', { name: users.tim.email }).getByRole('checkbox')
+ page.getByRole('row', { name: users.tim.username }).getByRole('checkbox')
).toHaveAttribute('data-state', 'unchecked');
await expect(
- page.getByRole('row', { name: users.craig.email }).getByRole('checkbox')
+ page.getByRole('row', { name: users.craig.username }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked');
});
@@ -108,12 +108,12 @@ test('Update user group custom claims', async ({ page }) => {
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
- await expect(page.locator('[data-type="success"]')).toHaveText(
+ await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await page.reload();
- await page.waitForLoadState('networkidle');
+ await page.waitForLoadState('networkidle');
// Check if custom claim is removed
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');