From c20e93b55c0aa28e0803c6b09e3bcdbf1a2eef66 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 13 Oct 2025 11:12:55 +0200 Subject: [PATCH] feat: add various improvements to the table component (#961) Co-authored-by: Kyle Mendell --- .../internal/controller/api_key_controller.go | 10 +- .../controller/audit_log_controller.go | 28 +- .../internal/controller/oidc_controller.go | 27 +- .../internal/controller/user_controller.go | 16 +- .../controller/user_group_controller.go | 12 +- backend/internal/dto/audit_log_dto.go | 7 - backend/internal/model/audit_log.go | 4 +- backend/internal/model/oidc.go | 4 +- backend/internal/model/user.go | 4 +- backend/internal/service/api_key_service.go | 4 +- backend/internal/service/audit_log_service.go | 42 ++- backend/internal/service/oidc_service.go | 22 +- .../internal/service/user_group_service.go | 11 +- backend/internal/service/user_service.go | 8 +- backend/internal/utils/list_request_util.go | 205 ++++++++++ backend/internal/utils/paging_util.go | 99 ----- backend/internal/utils/string_util.go | 29 +- backend/internal/utils/string_util_test.go | 32 +- backend/internal/utils/type_util.go | 35 ++ backend/internal/utils/type_util_test.go | 37 ++ frontend/messages/en.json | 8 +- .../src/lib/components/advanced-table.svelte | 217 ----------- .../src/lib/components/audit-log-list.svelte | 146 ++++--- frontend/src/lib/components/image-box.svelte | 10 +- .../signup/signup-token-list-modal.svelte | 158 ++++---- .../signup/signup-token-modal.svelte | 13 +- .../advanced-table-column-selection.svelte | 47 +++ .../table/advanced-table-filter.svelte | 128 +++++++ .../table/advanced-table-toolbar.svelte | 67 ++++ .../components/table/advanced-table.svelte | 356 ++++++++++++++++++ .../src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 + .../lib/components/ui/table/table-cell.svelte | 2 +- .../components/user-group-selection.svelte | 55 ++- frontend/src/lib/services/api-key-service.ts | 18 +- frontend/src/lib/services/api-service.ts | 14 +- .../src/lib/services/app-config-service.ts | 94 +++-- .../src/lib/services/audit-log-service.ts | 35 +- .../src/lib/services/custom-claim-service.ts | 12 +- frontend/src/lib/services/oidc-service.ts | 82 ++-- .../src/lib/services/user-group-service.ts | 30 +- frontend/src/lib/services/user-service.ts | 110 +++--- frontend/src/lib/services/version-service.ts | 26 +- frontend/src/lib/services/webauthn-service.ts | 51 ++- frontend/src/lib/types/advanced-table.type.ts | 26 ++ ...agination.type.ts => list-request.type.ts} | 6 +- frontend/src/routes/settings/+layout.ts | 3 +- .../settings/admin/api-keys/+page.svelte | 94 ++--- .../routes/settings/admin/api-keys/+page.ts | 18 - .../admin/api-keys/api-key-list.svelte | 91 +++-- .../update-application-images.svelte | 4 +- .../settings/admin/oidc-clients/+page.svelte | 5 +- .../settings/admin/oidc-clients/+page.ts | 18 - .../admin/oidc-clients/[id]/+page.svelte | 6 +- .../oidc-clients/oidc-client-list.svelte | 146 ++++--- .../settings/admin/user-groups/+page.svelte | 5 +- .../settings/admin/user-groups/+page.ts | 17 - .../admin/user-groups/[id]/+page.svelte | 8 +- .../admin/user-groups/user-group-list.svelte | 114 +++--- .../admin/user-groups/user-selection.svelte | 96 +++-- .../routes/settings/admin/users/+page.svelte | 24 +- .../src/routes/settings/admin/users/+page.ts | 33 -- .../settings/admin/users/[id]/+page.svelte | 6 +- .../admin/users/navigate-back-util.ts | 20 + .../settings/admin/users/user-list.svelte | 199 +++++----- .../src/routes/settings/apps/+page.svelte | 10 +- frontend/src/routes/settings/apps/+page.ts | 4 +- .../apps/authorized-oidc-client-card.svelte | 2 +- .../routes/settings/audit-log/+page.svelte | 7 +- .../src/routes/settings/audit-log/+page.ts | 15 - .../settings/audit-log/global/+page.svelte | 15 +- .../routes/settings/audit-log/global/+page.ts | 21 -- tests/setup/docker-compose.yml | 2 +- tests/specs/api-key.spec.ts | 6 +- tests/specs/oidc-client-settings.spec.ts | 10 +- tests/specs/user-group.spec.ts | 12 +- 76 files changed, 1948 insertions(+), 1434 deletions(-) create mode 100644 backend/internal/utils/list_request_util.go delete mode 100644 backend/internal/utils/paging_util.go create mode 100644 backend/internal/utils/type_util.go create mode 100644 backend/internal/utils/type_util_test.go delete mode 100644 frontend/src/lib/components/advanced-table.svelte create mode 100644 frontend/src/lib/components/table/advanced-table-column-selection.svelte create mode 100644 frontend/src/lib/components/table/advanced-table-filter.svelte create mode 100644 frontend/src/lib/components/table/advanced-table-toolbar.svelte create mode 100644 frontend/src/lib/components/table/advanced-table.svelte create mode 100644 frontend/src/lib/components/ui/skeleton/index.ts create mode 100644 frontend/src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 frontend/src/lib/types/advanced-table.type.ts rename frontend/src/lib/types/{pagination.type.ts => list-request.type.ts} (78%) delete mode 100644 frontend/src/routes/settings/admin/api-keys/+page.ts delete mode 100644 frontend/src/routes/settings/admin/oidc-clients/+page.ts delete mode 100644 frontend/src/routes/settings/admin/user-groups/+page.ts delete mode 100644 frontend/src/routes/settings/admin/users/+page.ts create mode 100644 frontend/src/routes/settings/admin/users/navigate-back-util.ts delete mode 100644 frontend/src/routes/settings/audit-log/+page.ts delete mode 100644 frontend/src/routes/settings/audit-log/global/+page.ts 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} + />
+ {/snippet} + + + + + {m.no_items_found()} + + {#each options as option (option)} + {@const isSelected = selectedValues.has(option.value)} + { + if (isSelected) { + selectedValues = new Set([...selectedValues].filter((v) => v !== option.value)); + } else { + selectedValues = new Set([...selectedValues, option.value]); + } + onChanged(selectedValues); + }} + > + {#if showCheckboxes} +
+ +
+ {/if} + {#if option.icon} + {@const Icon = option.icon} + + {/if} + + {option.label} +
+ {/each} +
+ {#if selectedValues.size > 0} + + + { + selectedValues = new Set(); + onChanged(selectedValues); + open = false; + }} + > + {m.clear_filters()} + + + {/if} +
+
+
+ diff --git a/frontend/src/lib/components/table/advanced-table-toolbar.svelte b/frontend/src/lib/components/table/advanced-table-toolbar.svelte new file mode 100644 index 00000000..df4c9086 --- /dev/null +++ b/frontend/src/lib/components/table/advanced-table-toolbar.svelte @@ -0,0 +1,67 @@ + + +
+
+ {#if !withoutSearch} + onSearch((e.currentTarget as HTMLInputElement).value)} + /> + {/if} + + {#each filterableColumns as col} + onFilterChange?.(selected, col.column)} + /> + {/each} + +
+
diff --git a/frontend/src/lib/components/table/advanced-table.svelte b/frontend/src/lib/components/table/advanced-table.svelte new file mode 100644 index 00000000..a4d33eb1 --- /dev/null +++ b/frontend/src/lib/components/table/advanced-table.svelte @@ -0,0 +1,356 @@ + + + + +{#if (items?.pagination.totalItems === 0 && searchValue === '') || tablePreferences.current.length === 0} +
+ +

{m.no_items_found()}

+
+{:else} + {#if !items} +
+ {#each Array((tablePreferences.current.length || 10) + 1) as _} +
+ +
+ {/each} +
+ {:else} +
+ + + + {#if selectedIds} + + onAllCheck(c as boolean)} + /> + + {/if} + + {#each visibleColumns as column} + + {#if column.sortable} + + {:else} + {column.label} + {/if} + + {/each} + {#if actions} + + {m.actions()} + + {/if} + + + + {#each items.data as item} + + {#if selectedIds} + + onCheck(c, item.id)} + /> + + {/if} + {#each visibleColumns as column} + + {#if column.value} + {column.value(item)} + {:else if column.cell} + {@render column.cell({ item })} + {:else if column.column && typeof item[column.column] === 'boolean'} + {item[column.column] ? m.enabled() : m.disabled()} + {:else if column.column} + {item[column.column]} + {/if} + + {/each} + {#if actions} + + + + + {m.toggle_menu()} + + + {#each actions(item).filter((a) => !a.hidden) as action} + action.onClick(item)} + disabled={action.disabled} + class={action.variant === 'danger' + ? 'text-red-500 focus:!text-red-700' + : ''} + > + {#if action.icon} + {@const Icon = action.icon} + + {/if} + {action.label} + + {/each} + + + + {/if} + + {/each} + + +
+ {/if} + +
+
+

{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/ui/skeleton/index.ts b/frontend/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 00000000..186db219 --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/frontend/src/lib/components/ui/skeleton/skeleton.svelte b/frontend/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 00000000..92fd792b --- /dev/null +++ b/frontend/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte index 57135936..76e18268 100644 --- a/frontend/src/lib/components/ui/table/table-cell.svelte +++ b/frontend/src/lib/components/ui/table/table-cell.svelte @@ -14,7 +14,7 @@ bind:this={ref} data-slot="table-cell" class={cn( - 'p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', + 'py-3 px-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className )} {...restProps} diff --git a/frontend/src/lib/components/user-group-selection.svelte b/frontend/src/lib/components/user-group-selection.svelte index 347f0c45..19eeb0bc 100644 --- a/frontend/src/lib/components/user-group-selection.svelte +++ b/frontend/src/lib/components/user-group-selection.svelte @@ -1,11 +1,9 @@ -{#if groups} - (groups = await userGroupService.list(o))} - columns={[{ label: m.name(), sortColumn: 'friendlyName' }]} - bind:selectedIds={selectedGroupIds} - {selectionDisabled} - > - {#snippet rows({ item })} - {item.friendlyName} - {/snippet} - -{/if} + diff --git a/frontend/src/lib/services/api-key-service.ts b/frontend/src/lib/services/api-key-service.ts index 1848d3f1..681a23b3 100644 --- a/frontend/src/lib/services/api-key-service.ts +++ b/frontend/src/lib/services/api-key-service.ts @@ -1,21 +1,19 @@ import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type'; -import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import APIService from './api-service'; export default class ApiKeyService extends APIService { - async list(options?: SearchPaginationSortRequest) { - const res = await this.api.get('/api-keys', { - params: options - }); + list = async (options?: ListRequestOptions) => { + const res = await this.api.get('/api-keys', { params: options }); return res.data as Paginated; - } + }; - async create(data: ApiKeyCreate): Promise { + create = async (data: ApiKeyCreate): Promise => { const res = await this.api.post('/api-keys', data); return res.data as ApiKeyResponse; - } + }; - async revoke(id: string): Promise { + revoke = async (id: string): Promise => { await this.api.delete(`/api-keys/${id}`); - } + }; } diff --git a/frontend/src/lib/services/api-service.ts b/frontend/src/lib/services/api-service.ts index 58d9bc0a..b03688e0 100644 --- a/frontend/src/lib/services/api-service.ts +++ b/frontend/src/lib/services/api-service.ts @@ -1,15 +1,13 @@ import axios from 'axios'; abstract class APIService { - api = axios.create({ - baseURL: '/api' - }); + protected api = axios.create({ baseURL: '/api' }); - constructor() { - if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) { - this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL; - } - } + constructor() { + if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) { + this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL; + } + } } export default APIService; diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index 73cff7e8..01b6b2ec 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -3,39 +3,33 @@ import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached- import APIService from './api-service'; export default class AppConfigService extends APIService { - async list(showAll = false) { + list = async (showAll = false) => { let url = '/application-configuration'; - if (showAll) { - url += '/all'; - } - + if (showAll) url += '/all'; const { data } = await this.api.get(url); - return this.parseConfigList(data); - } + return parseConfigList(data); + }; - async update(appConfig: AllAppConfig) { + update = async (appConfig: AllAppConfig) => { // Convert all values to string, stringifying JSON where needed const appConfigConvertedToString: Record = {}; for (const key in appConfig) { const value = (appConfig as any)[key]; - if (typeof value === 'object' && value !== null) { - appConfigConvertedToString[key] = JSON.stringify(value); - } else { - appConfigConvertedToString[key] = String(value); - } + appConfigConvertedToString[key] = + typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value); } const res = await this.api.put('/application-configuration', appConfigConvertedToString); - return this.parseConfigList(res.data); - } + return parseConfigList(res.data); + }; - async updateFavicon(favicon: File) { + updateFavicon = async (favicon: File) => { const formData = new FormData(); formData.append('file', favicon!); await this.api.put(`/application-images/favicon`, formData); } - async updateLogo(logo: File, light = true) { + updateLogo = async (logo: File, light = true) => { const formData = new FormData(); formData.append('file', logo!); @@ -43,52 +37,52 @@ export default class AppConfigService extends APIService { params: { light } }); cachedApplicationLogo.bustCache(light); - } + }; - async updateBackgroundImage(backgroundImage: File) { + updateBackgroundImage = async (backgroundImage: File) => { const formData = new FormData(); formData.append('file', backgroundImage!); await this.api.put(`/application-images/background`, formData); cachedBackgroundImage.bustCache(); - } + }; - async sendTestEmail() { + sendTestEmail = async () => { await this.api.post('/application-configuration/test-email'); - } + }; - async syncLdap() { + syncLdap = async () => { await this.api.post('/application-configuration/sync-ldap'); - } + }; +} - private parseConfigList(data: AppConfigRawResponse) { - const appConfig: Partial = {}; - data.forEach(({ key, value }) => { - (appConfig as any)[key] = this.parseValue(value); - }); +function parseConfigList(data: AppConfigRawResponse) { + const appConfig: Partial = {}; + data.forEach(({ key, value }) => { + (appConfig as any)[key] = parseValue(value); + }); - return appConfig as AllAppConfig; - } + return appConfig as AllAppConfig; +} - private parseValue(value: string) { - // Try to parse JSON first - try { - const parsed = JSON.parse(value); - if (typeof parsed === 'object' && parsed !== null) { - return parsed; - } - value = String(parsed); - } catch {} - - // Handle rest of the types - if (value === 'true') { - return true; - } else if (value === 'false') { - return false; - } else if (/^-?\d+(\.\d+)?$/.test(value)) { - return parseFloat(value); - } else { - return value; +function parseValue(value: string) { + // Try to parse JSON first + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'object' && parsed !== null) { + return parsed; } + value = String(parsed); + } catch {} + + // Handle rest of the types + if (value === 'true') { + return true; + } else if (value === 'false') { + return false; + } else if (/^-?\d+(\.\d+)?$/.test(value)) { + return parseFloat(value); + } else { + return value; } } diff --git a/frontend/src/lib/services/audit-log-service.ts b/frontend/src/lib/services/audit-log-service.ts index 68b537fa..a2f71bb7 100644 --- a/frontend/src/lib/services/audit-log-service.ts +++ b/frontend/src/lib/services/audit-log-service.ts @@ -1,34 +1,25 @@ -import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type'; -import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import type { AuditLog } from '$lib/types/audit-log.type'; +import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import APIService from './api-service'; -class AuditLogService extends APIService { - async list(options?: SearchPaginationSortRequest) { - const res = await this.api.get('/audit-logs', { - params: options - }); +export default class AuditLogService extends APIService { + list = async (options?: ListRequestOptions) => { + const res = await this.api.get('/audit-logs', { params: options }); return res.data as Paginated; - } + }; - async listAllLogs(options?: SearchPaginationSortRequest, filters?: AuditLogFilter) { - const res = await this.api.get('/audit-logs/all', { - params: { - ...options, - filters - } - }); + listAllLogs = async (options?: ListRequestOptions) => { + const res = await this.api.get('/audit-logs/all', { params: options }); return res.data as Paginated; - } + }; - async listClientNames() { + listClientNames = async () => { const res = await this.api.get('/audit-logs/filters/client-names'); return res.data; - } + }; - async listUsers() { + listUsers = async () => { const res = await this.api.get>('/audit-logs/filters/users'); return res.data; - } + }; } - -export default AuditLogService; diff --git a/frontend/src/lib/services/custom-claim-service.ts b/frontend/src/lib/services/custom-claim-service.ts index 87f58f40..6ceeccd3 100644 --- a/frontend/src/lib/services/custom-claim-service.ts +++ b/frontend/src/lib/services/custom-claim-service.ts @@ -2,18 +2,18 @@ import type { CustomClaim } from '$lib/types/custom-claim.type'; import APIService from './api-service'; export default class CustomClaimService extends APIService { - async getSuggestions() { + getSuggestions = async () => { const res = await this.api.get('/custom-claims/suggestions'); return res.data as string[]; - } + }; - async updateUserCustomClaims(userId: string, claims: CustomClaim[]) { + updateUserCustomClaims = async (userId: string, claims: CustomClaim[]) => { const res = await this.api.put(`/custom-claims/user/${userId}`, claims); return res.data as CustomClaim[]; - } + }; - async updateUserGroupCustomClaims(userGroupId: string, claims: CustomClaim[]) { + updateUserGroupCustomClaims = async (userGroupId: string, claims: CustomClaim[]) => { const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims); return res.data as CustomClaim[]; - } + }; } diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index d36bec09..7918155b 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -1,3 +1,4 @@ +import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { AccessibleOidcClient, AuthorizeResponse, @@ -9,12 +10,11 @@ import type { OidcClientWithAllowedUserGroupsCount, OidcDeviceCodeInfo } from '$lib/types/oidc.type'; -import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; import APIService from './api-service'; class OidcService extends APIService { - async authorize( + authorize = async ( clientId: string, scope: string, callbackURL: string, @@ -22,7 +22,7 @@ class OidcService extends APIService { codeChallenge?: string, codeChallengeMethod?: string, reauthenticationToken?: string - ) { + ) => { const res = await this.api.post('/oidc/authorize', { scope, nonce, @@ -34,45 +34,41 @@ class OidcService extends APIService { }); return res.data as AuthorizeResponse; - } + }; - async isAuthorizationRequired(clientId: string, scope: string) { + isAuthorizationRequired = async (clientId: string, scope: string) => { const res = await this.api.post('/oidc/authorization-required', { scope, clientId }); return res.data.authorizationRequired as boolean; - } + }; - async listClients(options?: SearchPaginationSortRequest) { + listClients = async (options?: ListRequestOptions) => { const res = await this.api.get('/oidc/clients', { params: options }); return res.data as Paginated; - } + }; - async createClient(client: OidcClientCreate) { - return (await this.api.post('/oidc/clients', client)).data as OidcClient; - } + createClient = async (client: OidcClientCreate) => + (await this.api.post('/oidc/clients', client)).data as OidcClient; - async removeClient(id: string) { + removeClient = async (id: string) => { await this.api.delete(`/oidc/clients/${id}`); - } + }; - async getClient(id: string) { - return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups; - } + getClient = async (id: string) => + (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups; - async getClientMetaData(id: string) { - return (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData; - } + getClientMetaData = async (id: string) => + (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData; - async updateClient(id: string, client: OidcClientUpdate) { - return (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient; - } + updateClient = async (id: string, client: OidcClientUpdate) => + (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient; - async updateClientLogo(client: OidcClient, image: File | null) { + updateClientLogo = async (client: OidcClient, image: File | null) => { if (client.hasLogo && !image) { await this.removeClientLogo(client.id); return; @@ -86,49 +82,45 @@ class OidcService extends APIService { await this.api.post(`/oidc/clients/${client.id}/logo`, formData); cachedOidcClientLogo.bustCache(client.id); - } + }; - async removeClientLogo(id: string) { + removeClientLogo = async (id: string) => { await this.api.delete(`/oidc/clients/${id}/logo`); cachedOidcClientLogo.bustCache(id); - } + }; - async createClientSecret(id: string) { - return (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string; - } + createClientSecret = async (id: string) => + (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string; - async updateAllowedUserGroups(id: string, userGroupIds: string[]) { + updateAllowedUserGroups = async (id: string, userGroupIds: string[]) => { const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds }); return res.data as OidcClientWithAllowedUserGroups; - } + }; - async verifyDeviceCode(userCode: string) { + verifyDeviceCode = async (userCode: string) => { return await this.api.post(`/oidc/device/verify?code=${userCode}`); - } + }; - async getDeviceCodeInfo(userCode: string): Promise { + getDeviceCodeInfo = async (userCode: string): Promise => { const response = await this.api.get(`/oidc/device/info?code=${userCode}`); return response.data; - } + }; - async getClientPreview(id: string, userId: string, scopes: string) { + getClientPreview = async (id: string, userId: string, scopes: string) => { const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, { params: { scopes } }); return response.data; - } - - async listOwnAccessibleClients(options?: SearchPaginationSortRequest) { - const res = await this.api.get('/oidc/users/me/clients', { - params: options - }); + }; + listOwnAccessibleClients = async (options?: ListRequestOptions) => { + const res = await this.api.get('/oidc/users/me/clients', { params: options }); return res.data as Paginated; - } + }; - async revokeOwnAuthorizedClient(clientId: string) { + revokeOwnAuthorizedClient = async (clientId: string) => { await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`); - } + }; } export default OidcService; diff --git a/frontend/src/lib/services/user-group-service.ts b/frontend/src/lib/services/user-group-service.ts index 2f353e57..d10e45c9 100644 --- a/frontend/src/lib/services/user-group-service.ts +++ b/frontend/src/lib/services/user-group-service.ts @@ -1,4 +1,4 @@ -import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { UserGroupCreate, UserGroupWithUserCount, @@ -7,34 +7,32 @@ import type { import APIService from './api-service'; export default class UserGroupService extends APIService { - async list(options?: SearchPaginationSortRequest) { - const res = await this.api.get('/user-groups', { - params: options - }); + list = async (options?: ListRequestOptions) => { + const res = await this.api.get('/user-groups', { params: options }); return res.data as Paginated; - } + }; - async get(id: string) { + get = async (id: string) => { const res = await this.api.get(`/user-groups/${id}`); return res.data as UserGroupWithUsers; - } + }; - async create(user: UserGroupCreate) { + create = async (user: UserGroupCreate) => { const res = await this.api.post('/user-groups', user); return res.data as UserGroupWithUsers; - } + }; - async update(id: string, user: UserGroupCreate) { + update = async (id: string, user: UserGroupCreate) => { const res = await this.api.put(`/user-groups/${id}`, user); return res.data as UserGroupWithUsers; - } + }; - async remove(id: string) { + remove = async (id: string) => { await this.api.delete(`/user-groups/${id}`); - } + }; - async updateUsers(id: string, userIds: string[]) { + updateUsers = async (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 7726ba84..c53f4c4f 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -1,5 +1,5 @@ import userStore from '$lib/stores/user-store'; -import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; +import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { SignupTokenDto } from '$lib/types/signup-token.type'; import type { UserGroup } from '$lib/types/user-group.type'; import type { User, UserCreate, UserSignUp } from '$lib/types/user.type'; @@ -8,125 +8,113 @@ import { get } from 'svelte/store'; import APIService from './api-service'; export default class UserService extends APIService { - async list(options?: SearchPaginationSortRequest) { - const res = await this.api.get('/users', { - params: options - }); + list = async (options?: ListRequestOptions) => { + const res = await this.api.get('/users', { params: options }); return res.data as Paginated; - } + }; - async get(id: string) { + get = async (id: string) => { const res = await this.api.get(`/users/${id}`); return res.data as User; - } + }; - async getCurrent() { + getCurrent = async () => { const res = await this.api.get('/users/me'); return res.data as User; - } + }; - async create(user: UserCreate) { + create = async (user: UserCreate) => { const res = await this.api.post('/users', user); return res.data as User; - } + }; - async getUserGroups(userId: string) { + getUserGroups = async (userId: string) => { const res = await this.api.get(`/users/${userId}/groups`); return res.data as UserGroup[]; - } + }; - async update(id: string, user: UserCreate) { + update = async (id: string, user: UserCreate) => { const res = await this.api.put(`/users/${id}`, user); return res.data as User; - } + }; - async updateCurrent(user: UserCreate) { + updateCurrent = async (user: UserCreate) => { const res = await this.api.put('/users/me', user); return res.data as User; - } + }; - async remove(id: string) { + remove = async (id: string) => { await this.api.delete(`/users/${id}`); - } + }; - async updateProfilePicture(userId: string, image: File) { + updateProfilePicture = async (userId: string, image: File) => { const formData = new FormData(); formData.append('file', image!); - await this.api.put(`/users/${userId}/profile-picture`, formData); cachedProfilePicture.bustCache(userId); - } + }; - async updateCurrentUsersProfilePicture(image: File) { + updateCurrentUsersProfilePicture = async (image: File) => { const formData = new FormData(); formData.append('file', image!); - await this.api.put('/users/me/profile-picture', formData); cachedProfilePicture.bustCache(get(userStore)!.id); - } + }; - async resetCurrentUserProfilePicture() { + resetCurrentUserProfilePicture = async () => { await this.api.delete(`/users/me/profile-picture`); cachedProfilePicture.bustCache(get(userStore)!.id); - } + }; - async resetProfilePicture(userId: string) { + resetProfilePicture = async (userId: string) => { await this.api.delete(`/users/${userId}/profile-picture`); cachedProfilePicture.bustCache(userId); - } + }; - async createOneTimeAccessToken(userId: string = 'me', ttl?: string|number) { - const res = await this.api.post(`/users/${userId}/one-time-access-token`, { - userId, - ttl, - }); + createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => { + const res = await this.api.post(`/users/${userId}/one-time-access-token`, { userId, ttl }); return res.data.token; - } + }; - async createSignupToken(ttl: string|number, usageLimit: number) { - const res = await this.api.post(`/signup-tokens`, { - ttl, - usageLimit - }); + createSignupToken = async (ttl: string | number, usageLimit: number) => { + const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit }); return res.data.token; - } + }; - async exchangeOneTimeAccessToken(token: string) { + exchangeOneTimeAccessToken = async (token: string) => { const res = await this.api.post(`/one-time-access-token/${token}`); return res.data as User; - } + }; - async requestOneTimeAccessEmailAsUnauthenticatedUser(email: string, redirectPath?: string) { + requestOneTimeAccessEmailAsUnauthenticatedUser = async (email: string, redirectPath?: string) => { await this.api.post('/one-time-access-email', { email, redirectPath }); - } + }; - async requestOneTimeAccessEmailAsAdmin(userId: string, ttl: string|number) { + requestOneTimeAccessEmailAsAdmin = async (userId: string, ttl: string | number) => { await this.api.post(`/users/${userId}/one-time-access-email`, { ttl }); - } + }; - async updateUserGroups(id: string, userGroupIds: string[]) { + updateUserGroups = async (id: string, userGroupIds: string[]) => { const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds }); return res.data as User; - } + }; - async signup(data: UserSignUp) { + signup = async (data: UserSignUp) => { const res = await this.api.post(`/signup`, data); return res.data as User; - } + }; - async signupInitialUser(data: UserSignUp) { + signupInitialUser = async (data: UserSignUp) => { const res = await this.api.post(`/signup/setup`, data); return res.data as User; - } + }; - async listSignupTokens(options?: SearchPaginationSortRequest) { - const res = await this.api.get('/signup-tokens', { - params: options - }); + listSignupTokens = async (options?: ListRequestOptions) => { + const res = await this.api.get('/signup-tokens', { params: options }); return res.data as Paginated; - } + }; - async deleteSignupToken(tokenId: string) { + deleteSignupToken = async (tokenId: string) => { await this.api.delete(`/signup-tokens/${tokenId}`); - } + }; } diff --git a/frontend/src/lib/services/version-service.ts b/frontend/src/lib/services/version-service.ts index e3f9ddb3..4a0c78dd 100644 --- a/frontend/src/lib/services/version-service.ts +++ b/frontend/src/lib/services/version-service.ts @@ -1,21 +1,13 @@ import { version as currentVersion } from '$app/environment'; -import axios from 'axios'; +import APIService from './api-service'; -async function getNewestVersion() { - const response = await axios - .get('/api/version/latest', { - timeout: 2000 - }) - .then((res) => res.data); +export default class VersionService extends APIService { + getNewestVersion = async () => { + const response = await this.api + .get('/version/latest', { timeout: 2000 }) + .then((res) => res.data); + return response.latestVersion; + }; - return response.latestVersion; + getCurrentVersion = () => currentVersion; } - -function getCurrentVersion() { - return currentVersion; -} - -export default { - getNewestVersion, - getCurrentVersion, -}; diff --git a/frontend/src/lib/services/webauthn-service.ts b/frontend/src/lib/services/webauthn-service.ts index 080efa26..13e37d7d 100644 --- a/frontend/src/lib/services/webauthn-service.ts +++ b/frontend/src/lib/services/webauthn-service.ts @@ -3,45 +3,36 @@ import type { User } from '$lib/types/user.type'; import APIService from './api-service'; import userStore from '$lib/stores/user-store'; import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser'; - class WebAuthnService extends APIService { - async getRegistrationOptions() { - return (await this.api.get(`/webauthn/register/start`)).data; - } + getRegistrationOptions = async () => (await this.api.get(`/webauthn/register/start`)).data; - async finishRegistration(body: RegistrationResponseJSON) { - return (await this.api.post(`/webauthn/register/finish`, body)).data as Passkey; - } + finishRegistration = async (body: RegistrationResponseJSON) => + (await this.api.post(`/webauthn/register/finish`, body)).data as Passkey; - async getLoginOptions() { - return (await this.api.get(`/webauthn/login/start`)).data; - } + getLoginOptions = async () => (await this.api.get(`/webauthn/login/start`)).data; - async finishLogin(body: AuthenticationResponseJSON) { - return (await this.api.post(`/webauthn/login/finish`, body)).data as User; - } + finishLogin = async (body: AuthenticationResponseJSON) => + (await this.api.post(`/webauthn/login/finish`, body)).data as User; - async logout() { - await this.api.post(`/webauthn/logout`); - userStore.clearUser(); - } + logout = async () => { + await this.api.post(`/webauthn/logout`); + userStore.clearUser(); + }; - async listCredentials() { - return (await this.api.get(`/webauthn/credentials`)).data as Passkey[]; - } + listCredentials = async () => (await this.api.get(`/webauthn/credentials`)).data as Passkey[]; - async removeCredential(id: string) { - await this.api.delete(`/webauthn/credentials/${id}`); - } + removeCredential = async (id: string) => { + await this.api.delete(`/webauthn/credentials/${id}`); + }; - async updateCredentialName(id: string, name: string) { - await this.api.patch(`/webauthn/credentials/${id}`, { name }); - } + updateCredentialName = async (id: string, name: string) => { + await this.api.patch(`/webauthn/credentials/${id}`, { name }); + }; - async reauthenticate(body?: AuthenticationResponseJSON) { - const res = await this.api.post('/webauthn/reauthenticate', body); - return res.data.reauthenticationToken as string; - } + reauthenticate = async (body?: AuthenticationResponseJSON) => { + const res = await this.api.post('/webauthn/reauthenticate', body); + return res.data.reauthenticationToken as string; + }; } export default WebAuthnService; diff --git a/frontend/src/lib/types/advanced-table.type.ts b/frontend/src/lib/types/advanced-table.type.ts new file mode 100644 index 00000000..b8486982 --- /dev/null +++ b/frontend/src/lib/types/advanced-table.type.ts @@ -0,0 +1,26 @@ +import type { Component, Snippet } from 'svelte'; + +export type AdvancedTableColumn> = { + label: string; + column?: keyof T & string; + key?: string; + value?: (item: T) => string | number | boolean | undefined; + cell?: Snippet<[{ item: T }]>; + sortable?: boolean; + filterableValues?: { + label: string; + value: string | boolean; + icon?: Component; + }[]; + hidden?: boolean; +}; +export type CreateAdvancedTableActions> = (item: T) => AdvancedTableAction[]; + +export type AdvancedTableAction = { + label: string; + icon?: Component; + variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost'; + onClick: (item: T) => void; + hidden?: boolean; + disabled?: boolean; +}; diff --git a/frontend/src/lib/types/pagination.type.ts b/frontend/src/lib/types/list-request.type.ts similarity index 78% rename from frontend/src/lib/types/pagination.type.ts rename to frontend/src/lib/types/list-request.type.ts index 6f44c9d6..1d528f28 100644 --- a/frontend/src/lib/types/pagination.type.ts +++ b/frontend/src/lib/types/list-request.type.ts @@ -8,13 +8,11 @@ export type SortRequest = { direction: 'asc' | 'desc'; }; -export type FilterMap = Record; - -export type SearchPaginationSortRequest = { +export type ListRequestOptions = { search?: string; pagination?: PaginationRequest; sort?: SortRequest; - filters?: FilterMap; + filters?: Record; }; export type PaginationResponse = { diff --git a/frontend/src/routes/settings/+layout.ts b/frontend/src/routes/settings/+layout.ts index 3357dd88..e4303823 100644 --- a/frontend/src/routes/settings/+layout.ts +++ b/frontend/src/routes/settings/+layout.ts @@ -1,8 +1,9 @@ -import versionService from '$lib/services/version-service'; +import VersionService from '$lib/services/version-service'; import type { AppVersionInformation } from '$lib/types/application-configuration'; import type { LayoutLoad } from './$types'; export const load: LayoutLoad = async () => { + const versionService = new VersionService(); const currentVersion = versionService.getCurrentVersion(); let newestVersion = null; diff --git a/frontend/src/routes/settings/admin/api-keys/+page.svelte b/frontend/src/routes/settings/admin/api-keys/+page.svelte index 5c2244c0..91ab3efd 100644 --- a/frontend/src/routes/settings/admin/api-keys/+page.svelte +++ b/frontend/src/routes/settings/admin/api-keys/+page.svelte @@ -12,22 +12,16 @@ import ApiKeyForm from './api-key-form.svelte'; import ApiKeyList from './api-key-list.svelte'; - let { data } = $props(); - let apiKeys = $state(data.apiKeys); - let apiKeysRequestOptions = $state(data.apiKeysRequestOptions); - const apiKeyService = new ApiKeyService(); let expandAddApiKey = $state(false); let apiKeyResponse = $state(null); + let listRef: ApiKeyList; async function createApiKey(apiKeyData: ApiKeyCreate) { try { const response = await apiKeyService.create(apiKeyData); apiKeyResponse = response; - - // After creation, reload the list of API keys - apiKeys = await apiKeyService.list(apiKeysRequestOptions); - + listRef.refresh(); return true; } catch (e) { axiosErrorToast(e); @@ -40,52 +34,46 @@ {m.api_keys()} -
- - -
-
- - - {m.create_api_key()} - - -
- {#if !expandAddApiKey} - - {:else} - - {/if} + + +
+
+ + + {m.create_api_key()} + +
- - {#if expandAddApiKey} -
- - - -
- {/if} - -
+ {#if !expandAddApiKey} + + {:else} + + {/if} +
+
+ {#if expandAddApiKey} +
+ + + +
+ {/if} +
-
- - - - - {m.manage_api_keys()} - - - - - - -
+ + + + + {m.manage_api_keys()} + + + + + + diff --git a/frontend/src/routes/settings/admin/api-keys/+page.ts b/frontend/src/routes/settings/admin/api-keys/+page.ts deleted file mode 100644 index 936326e8..00000000 --- a/frontend/src/routes/settings/admin/api-keys/+page.ts +++ /dev/null @@ -1,18 +0,0 @@ -import ApiKeyService from '$lib/services/api-key-service'; -import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - const apiKeyService = new ApiKeyService(); - - const apiKeysRequestOptions: SearchPaginationSortRequest = { - sort: { - column: 'lastUsedAt', - direction: 'desc' as const - } - }; - - const apiKeys = await apiKeyService.list(apiKeysRequestOptions); - - return { apiKeys, apiKeysRequestOptions }; -}; diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte index a52d2681..656306a5 100644 --- a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte +++ b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte @@ -1,31 +1,66 @@ (apiKeys = await apiKeyService.list(o))} + id="api-key-list" + bind:this={tableRef} + fetchCallback={apiKeyService.list} + defaultSort={{ column: 'lastUsedAt', direction: 'desc' }} withoutSearch - columns={[ - { label: m.name(), sortColumn: 'name' }, - { label: m.description() }, - { label: m.expires_at(), sortColumn: 'expiresAt' }, - { label: m.last_used(), sortColumn: 'lastUsedAt' }, - { label: m.actions(), hidden: true } - ]} -> - {#snippet rows({ item })} - {item.name} - {item.description || '-'} - {formatDate(item.expiresAt)} - {formatDate(item.lastUsedAt)} - - - - {/snippet} - + {columns} + {actions} +/> diff --git a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte index b3bb26b7..3b8ee2a0 100644 --- a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte @@ -32,7 +32,7 @@ /> - +
diff --git a/frontend/src/routes/settings/admin/oidc-clients/+page.ts b/frontend/src/routes/settings/admin/oidc-clients/+page.ts deleted file mode 100644 index 9626009e..00000000 --- a/frontend/src/routes/settings/admin/oidc-clients/+page.ts +++ /dev/null @@ -1,18 +0,0 @@ -import OIDCService from '$lib/services/oidc-service'; -import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageLoad } from './$types'; - -export const load: PageLoad = async () => { - const oidcService = new OIDCService(); - - const clientsRequestOptions: SearchPaginationSortRequest = { - sort: { - column: 'name', - direction: 'asc' - } - }; - - const clients = await oidcService.listClients(clientsRequestOptions); - - return { clients, clientsRequestOptions }; -}; diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index f07ea3bd..737133d2 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -16,6 +16,7 @@ import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; import { slide } from 'svelte/transition'; + import { backNavigate } from '../../users/navigate-back-util'; import OidcForm from '../oidc-client-form.svelte'; import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte'; @@ -28,6 +29,7 @@ let showPreview = $state(false); const oidcService = new OidcService(); + const backNavigation = backNavigate('/settings/admin/oidc-clients'); const setupDetails = $state({ [m.authorization_url()]: `https://${page.url.host}/authorize`, @@ -107,8 +109,8 @@
- {m.back()} {m.back()}
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte index a430314a..63f1118b 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-list.svelte @@ -1,27 +1,82 @@ +{#snippet LogoCell({ item }: { item: OidcClientWithAllowedUserGroupsCount })} + {#if item.hasLogo} + + {:else} +
+ {item.name.charAt(0).toUpperCase()} +
+ {/if} +{/snippet} + (clients = await oidcService.listClients(o))} - columns={[ - { label: m.logo() }, - { label: m.name(), sortColumn: 'name' }, - { label: m.oidc_allowed_group_count(), sortColumn: 'allowedUserGroupsCount' }, - { label: m.actions(), hidden: true } - ]} -> - {#snippet rows({ item })} - - {#if item.hasLogo} - - {/if} - - {item.name} - {item.allowedUserGroupsCount > 0 - ? item.allowedUserGroupsCount - : m.unrestricted()} - -
- - -
-
- {/snippet} -
+ id="oidc-client-list" + bind:this={tableRef} + fetchCallback={oidcService.listClients} + defaultSort={{ column: 'name', direction: 'asc' }} + {columns} + {actions} +/> diff --git a/frontend/src/routes/settings/admin/user-groups/+page.svelte b/frontend/src/routes/settings/admin/user-groups/+page.svelte index 84cae711..2307c74a 100644 --- a/frontend/src/routes/settings/admin/user-groups/+page.svelte +++ b/frontend/src/routes/settings/admin/user-groups/+page.svelte @@ -12,9 +12,6 @@ import UserGroupForm from './user-group-form.svelte'; import UserGroupList from './user-group-list.svelte'; - let { data } = $props(); - let userGroups = $state(data.userGroups); - let userGroupsRequestOptions = $state(data.userGroupsRequestOptions); let expandAddUserGroup = $state(false); const userGroupService = new UserGroupService(); @@ -79,7 +76,7 @@ - +
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');