mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 02:02:58 +03:00
feat: add various improvements to the table component (#961)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
@@ -45,15 +45,11 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
|||||||
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||||
// @Router /api/api-keys [get]
|
// @Router /api/api-keys [get]
|
||||||
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||||
|
listRequestOptions := utils.ParseListRequestOptions(ctx)
|
||||||
|
|
||||||
userID := ctx.GetString("userID")
|
userID := ctx.GetString("userID")
|
||||||
|
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions)
|
||||||
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = ctx.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -41,18 +41,12 @@ type AuditLogController struct {
|
|||||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
// @Router /api/audit-logs [get]
|
// @Router /api/audit-logs [get]
|
||||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
|
|
||||||
err := c.ShouldBindQuery(&sortedPaginationRequest)
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
// Fetch audit logs for the user
|
// 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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
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 pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort[column] query string false "Column to sort by"
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
// @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]
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
// @Router /api/audit-logs/all [get]
|
// @Router /api/audit-logs/all [get]
|
||||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var filters dto.AuditLogFilterDto
|
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
|
||||||
if err := c.ShouldBindQuery(&filters); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -403,13 +403,9 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
// @Router /api/oidc/clients [get]
|
// @Router /api/oidc/clients [get]
|
||||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -685,12 +681,9 @@ func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
|
||||||
return
|
|
||||||
}
|
|
||||||
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -741,15 +734,11 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||||
// @Router /api/oidc/users/me/clients [get]
|
// @Router /api/oidc/users/me/clients [get]
|
||||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||||
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -104,13 +104,9 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
|||||||
// @Router /api/users [get]
|
// @Router /api/users [get]
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -574,13 +570,9 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
|
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
|
||||||
// @Router /api/signup-tokens [get]
|
// @Router /api/signup-tokens [get]
|
||||||
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
|
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
|
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -47,16 +47,10 @@ type UserGroupController struct {
|
|||||||
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
||||||
// @Router /api/user-groups [get]
|
// @Router /api/user-groups [get]
|
||||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
|
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -70,7 +64,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
|
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,10 +17,3 @@ type AuditLogDto struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Data map[string]string `json:"data"`
|
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]"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
type AuditLog struct {
|
type AuditLog struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Event AuditLogEvent `sortable:"true"`
|
Event AuditLogEvent `sortable:"true" filterable:"true"`
|
||||||
IpAddress *string `sortable:"true"`
|
IpAddress *string `sortable:"true"`
|
||||||
Country string `sortable:"true"`
|
Country string `sortable:"true"`
|
||||||
City string `sortable:"true"`
|
City string `sortable:"true"`
|
||||||
@@ -17,7 +17,7 @@ type AuditLog struct {
|
|||||||
Username string `gorm:"-"`
|
Username string `gorm:"-"`
|
||||||
Data AuditLogData
|
Data AuditLogData
|
||||||
|
|
||||||
UserID string
|
UserID string `filterable:"true"`
|
||||||
User User
|
User User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ type OidcClient struct {
|
|||||||
LogoutCallbackURLs UrlList
|
LogoutCallbackURLs UrlList
|
||||||
ImageType *string
|
ImageType *string
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
PkceEnabled bool
|
PkceEnabled bool `filterable:"true"`
|
||||||
RequiresReauthentication bool
|
RequiresReauthentication bool `filterable:"true"`
|
||||||
Credentials OidcClientCredentials
|
Credentials OidcClientCredentials
|
||||||
LaunchURL *string
|
LaunchURL *string
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ type User struct {
|
|||||||
FirstName string `sortable:"true"`
|
FirstName string `sortable:"true"`
|
||||||
LastName string `sortable:"true"`
|
LastName string `sortable:"true"`
|
||||||
DisplayName string `sortable:"true"`
|
DisplayName string `sortable:"true"`
|
||||||
IsAdmin bool `sortable:"true"`
|
IsAdmin bool `sortable:"true" filterable:"true"`
|
||||||
Locale *string
|
Locale *string
|
||||||
LdapID *string
|
LdapID *string
|
||||||
Disabled bool `sortable:"true"`
|
Disabled bool `sortable:"true" filterable:"true"`
|
||||||
|
|
||||||
CustomClaims []CustomClaim
|
CustomClaims []CustomClaim
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
|
|||||||
return &ApiKeyService{db: db, emailService: emailService}
|
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.
|
query := s.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Where("user_id = ?", userID).
|
Where("user_id = ?", userID).
|
||||||
Model(&model.ApiKey{})
|
Model(&model.ApiKey{})
|
||||||
|
|
||||||
var apiKeys []model.ApiKey
|
var apiKeys []model.ApiKey
|
||||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
|
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, utils.PaginationResponse{}, err
|
return nil, utils.PaginationResponse{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
userAgentParser "github.com/mileusna/useragent"
|
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/model"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
"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
|
// 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
|
var logs []model.AuditLog
|
||||||
query := s.db.
|
query := s.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Model(&model.AuditLog{}).
|
Model(&model.AuditLog{}).
|
||||||
Where("user_id = ?", userID)
|
Where("user_id = ?", userID)
|
||||||
|
|
||||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
|
||||||
return logs, pagination, err
|
return logs, pagination, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +151,7 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
|||||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
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
|
var logs []model.AuditLog
|
||||||
|
|
||||||
query := s.db.
|
query := s.db.
|
||||||
@@ -160,33 +159,36 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
|
|||||||
Preload("User").
|
Preload("User").
|
||||||
Model(&model.AuditLog{})
|
Model(&model.AuditLog{})
|
||||||
|
|
||||||
if filters.UserID != "" {
|
if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
|
||||||
query = query.Where("user_id = ?", filters.UserID)
|
|
||||||
}
|
|
||||||
if filters.Event != "" {
|
|
||||||
query = query.Where("event = ?", filters.Event)
|
|
||||||
}
|
|
||||||
if filters.ClientName != "" {
|
|
||||||
dialect := s.db.Name()
|
dialect := s.db.Name()
|
||||||
switch dialect {
|
switch dialect {
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
|
query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
|
||||||
case "postgres":
|
case "postgres":
|
||||||
query = query.Where("data->>'clientName' = ?", filters.ClientName)
|
query = query.Where("data->>'clientName' IN ?", clientName)
|
||||||
default:
|
default:
|
||||||
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if filters.Location != "" {
|
|
||||||
switch filters.Location {
|
if locations, ok := listRequestOptions.Filters["location"]; ok {
|
||||||
case "external":
|
mapped := make([]string, 0, len(locations))
|
||||||
query = query.Where("country != 'Internal Network'")
|
for _, v := range locations {
|
||||||
case "internal":
|
if s, ok := v.(string); ok {
|
||||||
query = query.Where("country = 'Internal Network'")
|
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 {
|
if err != nil {
|
||||||
return nil, pagination, err
|
return nil, pagination, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -692,7 +692,7 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
|
|||||||
return client, nil
|
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
|
var clients []model.OidcClient
|
||||||
|
|
||||||
query := s.db.
|
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
|
// 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)").
|
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").
|
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
|
||||||
Group("oidc_clients.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
|
return clients, response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
|
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
|
||||||
return clients, response, err
|
return clients, response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,7 +1350,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
|
|||||||
return count, nil
|
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.
|
query := s.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
@@ -1359,7 +1359,7 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
|
|||||||
Where("user_id = ?", userID)
|
Where("user_id = ?", userID)
|
||||||
|
|
||||||
var authorizedClients []model.UserAuthorizedOidcClient
|
var authorizedClients []model.UserAuthorizedOidcClient
|
||||||
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &authorizedClients)
|
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
|
||||||
|
|
||||||
return authorizedClients, response, err
|
return authorizedClients, response, err
|
||||||
}
|
}
|
||||||
@@ -1392,7 +1392,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
|
|||||||
return nil
|
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()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -1439,13 +1439,13 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
|||||||
|
|
||||||
// Handle custom sorting for lastUsedAt column
|
// Handle custom sorting for lastUsedAt column
|
||||||
var response utils.PaginationResponse
|
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.
|
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).
|
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 {
|
if err != nil {
|
||||||
return nil, utils.PaginationResponse{}, err
|
return nil, utils.PaginationResponse{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserG
|
|||||||
return &UserGroupService{db: db, appConfigService: appConfigService}
|
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.
|
query := s.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Preload("CustomClaims").
|
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
|
// 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)").
|
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").
|
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
|
||||||
Group("user_groups.id").
|
Group("user_groups.id").
|
||||||
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
|
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
|
||||||
|
|
||||||
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
|
|
||||||
return groups, response, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
|
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
|
||||||
return groups, response, err
|
return groups, response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
var users []model.User
|
||||||
query := s.db.WithContext(ctx).
|
query := s.db.WithContext(ctx).
|
||||||
Model(&model.User{}).
|
Model(&model.User{}).
|
||||||
@@ -60,7 +60,7 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
|
|||||||
searchPattern, searchPattern, searchPattern, searchPattern)
|
searchPattern, searchPattern, searchPattern, searchPattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
|
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
|
||||||
|
|
||||||
return users, pagination, err
|
return users, pagination, err
|
||||||
}
|
}
|
||||||
@@ -794,11 +794,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
return user, accessToken, nil
|
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
|
var tokens []model.SignupToken
|
||||||
query := s.db.WithContext(ctx).Model(&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
|
return tokens, pagination, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
205
backend/internal/utils/list_request_util.go
Normal file
205
backend/internal/utils/list_request_util.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -81,26 +81,21 @@ func CapitalizeFirstLetter(str string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func CamelCaseToSnakeCase(str string) string {
|
var (
|
||||||
result := strings.Builder{}
|
reAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`) // ABCd -> AB_Cd
|
||||||
result.Grow(int(float32(len(str)) * 1.1))
|
reLowerToUpper = regexp.MustCompile(`([a-z0-9])([A-Z])`) // aB -> a_B
|
||||||
for i, r := range str {
|
)
|
||||||
if unicode.IsUpper(r) && i > 0 {
|
|
||||||
result.WriteByte('_')
|
func CamelCaseToSnakeCase(s string) string {
|
||||||
}
|
s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
|
||||||
result.WriteRune(unicode.ToLower(r))
|
s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
|
||||||
}
|
return strings.ToLower(s)
|
||||||
return result.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
|
||||||
|
|
||||||
func CamelCaseToScreamingSnakeCase(s string) string {
|
func CamelCaseToScreamingSnakeCase(s string) string {
|
||||||
// Insert underscores before uppercase letters (except the first one)
|
s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
|
||||||
snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
|
s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
|
||||||
|
return strings.ToUpper(s)
|
||||||
// Convert to uppercase
|
|
||||||
return strings.ToUpper(snake)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode
|
// GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
|
|||||||
{"simple camelCase", "camelCase", "camel_case"},
|
{"simple camelCase", "camelCase", "camel_case"},
|
||||||
{"PascalCase", "PascalCase", "pascal_case"},
|
{"PascalCase", "PascalCase", "pascal_case"},
|
||||||
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_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 lowercase word", "word", "word"},
|
||||||
{"single uppercase word", "WORD", "w_o_r_d"},
|
{"single uppercase word", "WORD", "word"},
|
||||||
{"with numbers", "camel123Case", "camel123_case"},
|
{"with numbers", "camel123Case", "camel123_case"},
|
||||||
{"with numbers in middle", "model2Name", "model2_name"},
|
{"with numbers in middle", "model2Name", "model2_name"},
|
||||||
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
|
{"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) {
|
func TestGetFirstCharacter(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
35
backend/internal/utils/type_util.go
Normal file
35
backend/internal/utils/type_util.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
37
backend/internal/utils/type_util_test.go
Normal file
37
backend/internal/utils/type_util_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -455,5 +455,11 @@
|
|||||||
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> or <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> or <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||||
"invalid_url": "Invalid URL",
|
"invalid_url": "Invalid URL",
|
||||||
"require_user_email": "Require Email Address",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
<script lang="ts" generics="T extends {id:string}">
|
|
||||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
|
||||||
import * as Pagination from '$lib/components/ui/pagination';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
|
||||||
import Empty from '$lib/icons/empty.svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
|
||||||
import { debounced } from '$lib/utils/debounce-util';
|
|
||||||
import { cn } from '$lib/utils/style';
|
|
||||||
import { ChevronDown } from '@lucide/svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import Button from './ui/button/button.svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
items,
|
|
||||||
requestOptions = $bindable(),
|
|
||||||
selectedIds = $bindable(),
|
|
||||||
withoutSearch = false,
|
|
||||||
selectionDisabled = false,
|
|
||||||
onRefresh,
|
|
||||||
columns,
|
|
||||||
rows
|
|
||||||
}: {
|
|
||||||
items: Paginated<T>;
|
|
||||||
requestOptions: SearchPaginationSortRequest;
|
|
||||||
selectedIds?: string[];
|
|
||||||
withoutSearch?: boolean;
|
|
||||||
selectionDisabled?: boolean;
|
|
||||||
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
|
|
||||||
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
|
|
||||||
rows: Snippet<[{ item: T }]>;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let searchValue = $state('');
|
|
||||||
let availablePageSizes: number[] = [20, 50, 100];
|
|
||||||
|
|
||||||
let allChecked = $derived.by(() => {
|
|
||||||
if (!selectedIds || items.data.length === 0) return false;
|
|
||||||
for (const item of items.data) {
|
|
||||||
if (!selectedIds.includes(item.id)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSearch = debounced(async (search: string) => {
|
|
||||||
requestOptions.search = search;
|
|
||||||
await onRefresh(requestOptions);
|
|
||||||
searchValue = search;
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
async function onAllCheck(checked: boolean) {
|
|
||||||
const pageIds = items.data.map((item) => item.id);
|
|
||||||
const current = selectedIds ?? [];
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
selectedIds = Array.from(new Set([...current, ...pageIds]));
|
|
||||||
} else {
|
|
||||||
selectedIds = current.filter((id) => !pageIds.includes(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCheck(checked: boolean, id: string) {
|
|
||||||
const current = selectedIds ?? [];
|
|
||||||
if (checked) {
|
|
||||||
selectedIds = Array.from(new Set([...current, id]));
|
|
||||||
} else {
|
|
||||||
selectedIds = current.filter((selectedId) => selectedId !== id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPageChange(page: number) {
|
|
||||||
requestOptions.pagination = { limit: items.pagination.itemsPerPage, page };
|
|
||||||
onRefresh(requestOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPageSizeChange(size: number) {
|
|
||||||
requestOptions.pagination = { limit: size, page: 1 };
|
|
||||||
onRefresh(requestOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
|
|
||||||
if (!column) return;
|
|
||||||
|
|
||||||
requestOptions.sort = { column, direction };
|
|
||||||
onRefresh(requestOptions);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !withoutSearch}
|
|
||||||
<Input
|
|
||||||
value={searchValue}
|
|
||||||
class={cn(
|
|
||||||
'relative z-50 mb-4 max-w-sm',
|
|
||||||
items.data.length == 0 && searchValue == '' && 'hidden'
|
|
||||||
)}
|
|
||||||
placeholder={m.search()}
|
|
||||||
type="text"
|
|
||||||
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if items.data.length === 0 && searchValue === ''}
|
|
||||||
<div class="my-5 flex flex-col items-center">
|
|
||||||
<Empty class="text-muted-foreground h-20" />
|
|
||||||
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
{#if selectedIds}
|
|
||||||
<Table.Head class="w-12">
|
|
||||||
<Checkbox
|
|
||||||
disabled={selectionDisabled}
|
|
||||||
checked={allChecked}
|
|
||||||
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
|
|
||||||
/>
|
|
||||||
</Table.Head>
|
|
||||||
{/if}
|
|
||||||
{#each columns as column}
|
|
||||||
<Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
|
|
||||||
{#if column.sortColumn}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex items-center"
|
|
||||||
onclick={() =>
|
|
||||||
onSort(
|
|
||||||
column.sortColumn,
|
|
||||||
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
{#if requestOptions.sort?.column === column.sortColumn}
|
|
||||||
<ChevronDown
|
|
||||||
class={cn(
|
|
||||||
'ml-2 size-4',
|
|
||||||
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
{column.label}
|
|
||||||
{/if}
|
|
||||||
</Table.Head>
|
|
||||||
{/each}
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each items.data as item}
|
|
||||||
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
|
||||||
{#if selectedIds}
|
|
||||||
<Table.Cell class="w-12">
|
|
||||||
<Checkbox
|
|
||||||
disabled={selectionDisabled}
|
|
||||||
checked={selectedIds.includes(item.id)}
|
|
||||||
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
{@render rows({ item })}
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
value={items.pagination.itemsPerPage.toString()}
|
|
||||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="h-9 w-[80px]">
|
|
||||||
{items.pagination.itemsPerPage}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each availablePageSizes as size}
|
|
||||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
<Pagination.Root
|
|
||||||
class="mx-0 w-auto"
|
|
||||||
count={items.pagination.totalItems}
|
|
||||||
perPage={items.pagination.itemsPerPage}
|
|
||||||
{onPageChange}
|
|
||||||
page={items.pagination.currentPage}
|
|
||||||
>
|
|
||||||
{#snippet children({ pages })}
|
|
||||||
<Pagination.Content class="flex justify-end">
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.PrevButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
{#each pages as page (page.key)}
|
|
||||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
|
||||||
{page.value}
|
|
||||||
</Pagination.Link>
|
|
||||||
</Pagination.Item>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<Pagination.Item>
|
|
||||||
<Pagination.NextButton />
|
|
||||||
</Pagination.Item>
|
|
||||||
</Pagination.Content>
|
|
||||||
{/snippet}
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,69 +1,111 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import {translateAuditLogEvent} from "$lib/utils/audit-log-translator";
|
|
||||||
import AuditLogService from '$lib/services/audit-log-service';
|
import AuditLogService from '$lib/services/audit-log-service';
|
||||||
import type { AuditLog } from '$lib/types/audit-log.type';
|
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
|
||||||
|
import { translateAuditLogEvent } from '$lib/utils/audit-log-translator';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
auditLogs,
|
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
requestOptions
|
filters
|
||||||
}: {
|
}: {
|
||||||
auditLogs: Paginated<AuditLog>;
|
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
requestOptions: SearchPaginationSortRequest;
|
filters?: AuditLogFilter;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const auditLogService = new AuditLogService();
|
const auditLogService = new AuditLogService();
|
||||||
|
let tableRef: AdvancedTable<AuditLog>;
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<AuditLog>[] = [
|
||||||
|
{
|
||||||
|
label: m.time(),
|
||||||
|
column: 'createdAt',
|
||||||
|
sortable: true,
|
||||||
|
value: (item) => new Date(item.createdAt).toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.username(),
|
||||||
|
column: 'username',
|
||||||
|
hidden: !isAdmin,
|
||||||
|
value: (item) => item.username ?? m.unknown()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.event(),
|
||||||
|
column: 'event',
|
||||||
|
sortable: true,
|
||||||
|
cell: EventCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.approximate_location(),
|
||||||
|
key: 'location',
|
||||||
|
value: (item) => formatLocation(item)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.ip_address(),
|
||||||
|
column: 'ipAddress',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.device(),
|
||||||
|
column: 'device',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.client(),
|
||||||
|
key: 'client',
|
||||||
|
value: (item) => item.data?.clientName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (filters) {
|
||||||
|
tableRef?.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
await tableRef.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(log: AuditLog) {
|
||||||
|
if (log.city && log.country) {
|
||||||
|
return `${log.city}, ${log.country}`;
|
||||||
|
} else if (log.country) {
|
||||||
|
return log.country;
|
||||||
|
} else {
|
||||||
|
return m.unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapFilters(filters?: Record<string, string>) {
|
||||||
|
if (!filters) return undefined;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(filters)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map(([key, value]) => [key, [value]])
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet EventCell({ item }: { item: AuditLog })}
|
||||||
|
<Badge class="rounded-full" variant="outline">
|
||||||
|
{translateAuditLogEvent(item.event)}
|
||||||
|
</Badge>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={auditLogs}
|
id="audit-log-list-{isAdmin ? 'admin' : 'user'}"
|
||||||
{requestOptions}
|
bind:this={tableRef}
|
||||||
onRefresh={async (options) =>
|
fetchCallback={async (options) =>
|
||||||
isAdmin
|
isAdmin
|
||||||
? (auditLogs = await auditLogService.listAllLogs(options))
|
? await auditLogService.listAllLogs({
|
||||||
: (auditLogs = await auditLogService.list(options))}
|
...options,
|
||||||
columns={[
|
filters: wrapFilters(filters)
|
||||||
{ label: m.time(), sortColumn: 'createdAt' },
|
})
|
||||||
...(isAdmin ? [{ label: 'Username' }] : []),
|
: await auditLogService.list(options)}
|
||||||
{ label: m.event(), sortColumn: 'event' },
|
defaultSort={{ column: 'createdAt', direction: 'desc' }}
|
||||||
{ label: m.approximate_location(), sortColumn: 'city' },
|
|
||||||
{ label: m.ip_address(), sortColumn: 'ipAddress' },
|
|
||||||
{ label: m.device(), sortColumn: 'device' },
|
|
||||||
{ label: m.client() }
|
|
||||||
]}
|
|
||||||
withoutSearch
|
withoutSearch
|
||||||
>
|
{columns}
|
||||||
{#snippet rows({ item })}
|
/>
|
||||||
<Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
|
|
||||||
{#if isAdmin}
|
|
||||||
<Table.Cell>
|
|
||||||
{#if item.username}
|
|
||||||
{item.username}
|
|
||||||
{:else}
|
|
||||||
Unknown User
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge class="rounded-full" variant="outline">{translateAuditLogEvent(item.event)}</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{#if item.city && item.country}
|
|
||||||
{item.city}, {item.country}
|
|
||||||
{:else if item.country}
|
|
||||||
{item.country}
|
|
||||||
{:else}
|
|
||||||
{m.unknown()}
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{item.ipAddress}</Table.Cell>
|
|
||||||
<Table.Cell>{item.device}</Table.Cell>
|
|
||||||
<Table.Cell>{item.data.clientName}</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
|
|||||||
@@ -12,14 +12,10 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={'bg-muted flex items-center justify-center rounded-2xl p-3'}>
|
<div class={cn('bg-muted flex items-center justify-center rounded-2xl p-3', props.class)}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<LucideImageOff class={cn('text-muted-foreground p-5', props.class)} />
|
<LucideImageOff class="text-muted-foreground p-5" />
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img {...props} class="aspect-square object-contain" onerror={() => (error = true)} />
|
||||||
{...props}
|
|
||||||
class={cn('object-contain aspect-square', props.class)}
|
|
||||||
onerror={() => (error = true)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge';
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge';
|
||||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { Copy, Ellipsis, Trash2 } from '@lucide/svelte';
|
import { Copy, Trash2 } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(),
|
open = $bindable()
|
||||||
signupTokens = $bindable(),
|
|
||||||
signupTokensRequestOptions,
|
|
||||||
onTokenDeleted
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
signupTokens: Paginated<SignupTokenDto>;
|
|
||||||
signupTokensRequestOptions: SearchPaginationSortRequest;
|
|
||||||
onTokenDeleted?: () => Promise<void>;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
let tableRef: AdvancedTable<SignupTokenDto>;
|
||||||
|
|
||||||
function formatDate(dateStr: string | undefined) {
|
function formatDate(dateStr: string | undefined) {
|
||||||
if (!dateStr) return m.never();
|
if (!dateStr) return m.never();
|
||||||
@@ -44,12 +40,8 @@
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await userService.deleteSignupToken(token.id);
|
await userService.deleteSignupToken(token.id);
|
||||||
|
await tableRef.refresh();
|
||||||
toast.success(m.signup_token_deleted_successfully());
|
toast.success(m.signup_token_deleted_successfully());
|
||||||
|
|
||||||
// Refresh the tokens
|
|
||||||
if (onTokenDeleted) {
|
|
||||||
await onTokenDeleted();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
}
|
}
|
||||||
@@ -98,8 +90,69 @@
|
|||||||
axiosErrorToast(err);
|
axiosErrorToast(err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<SignupTokenDto>[] = [
|
||||||
|
{ label: m.token(), column: 'token', cell: TokenCell },
|
||||||
|
{ label: m.status(), key: 'status', cell: StatusCell },
|
||||||
|
{
|
||||||
|
label: m.usage(),
|
||||||
|
column: 'usageCount',
|
||||||
|
sortable: true,
|
||||||
|
cell: UsageCell
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.expires(),
|
||||||
|
column: 'expiresAt',
|
||||||
|
sortable: true,
|
||||||
|
value: (item) => formatDate(item.expiresAt)
|
||||||
|
},
|
||||||
|
{ label: 'Usage Limit', column: 'usageLimit' },
|
||||||
|
{
|
||||||
|
label: m.created(),
|
||||||
|
column: 'createdAt',
|
||||||
|
sortable: true,
|
||||||
|
hidden: true,
|
||||||
|
value: (item) => formatDate(item.createdAt)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: CreateAdvancedTableActions<SignupTokenDto> = (_) => [
|
||||||
|
{
|
||||||
|
label: m.copy(),
|
||||||
|
icon: Copy,
|
||||||
|
onClick: (token) => copySignupLink(token)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.delete(),
|
||||||
|
icon: Trash2,
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: (token) => deleteToken(token)
|
||||||
|
}
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet TokenCell({ item }: { item: SignupTokenDto })}
|
||||||
|
<span class="font-mono text-xs">
|
||||||
|
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet StatusCell({ item }: { item: SignupTokenDto })}
|
||||||
|
{@const status = getTokenStatus(item)}
|
||||||
|
{@const statusBadge = getStatusBadge(status)}
|
||||||
|
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||||
|
{statusBadge.text}
|
||||||
|
</Badge>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet UsageCell({ item }: { item: SignupTokenDto })}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{item.usageCount}
|
||||||
|
{m.of()}
|
||||||
|
{item.usageLimit}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<Dialog.Root {open} {onOpenChange}>
|
<Dialog.Root {open} {onOpenChange}>
|
||||||
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
@@ -111,70 +164,13 @@
|
|||||||
|
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={signupTokens}
|
id="signup-token-list"
|
||||||
requestOptions={signupTokensRequestOptions}
|
|
||||||
withoutSearch={true}
|
withoutSearch={true}
|
||||||
onRefresh={async (options) => {
|
fetchCallback={userService.listSignupTokens}
|
||||||
const result = await userService.listSignupTokens(options);
|
bind:this={tableRef}
|
||||||
signupTokens = result;
|
{columns}
|
||||||
return result;
|
{actions}
|
||||||
}}
|
/>
|
||||||
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 })}
|
|
||||||
<Table.Cell class="font-mono text-xs">
|
|
||||||
{item.token.substring(0, 2)}...{item.token.substring(item.token.length - 4)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{@const status = getTokenStatus(item)}
|
|
||||||
{@const statusBadge = getStatusBadge(status)}
|
|
||||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
|
||||||
{statusBadge.text}
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{`${item.usageCount} ${m.of()} ${item.usageLimit}`}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-sm">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{formatDate(item.expiresAt)}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-sm">
|
|
||||||
{formatDate(item.createdAt)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
|
||||||
<Ellipsis class="size-4" />
|
|
||||||
<span class="sr-only">{m.toggle_menu()}</span>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content align="end">
|
|
||||||
<DropdownMenu.Item onclick={() => copySignupLink(item)}>
|
|
||||||
<Copy class="mr-2 size-4" />
|
|
||||||
{m.copy()}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
class="text-red-500 focus:!text-red-700"
|
|
||||||
onclick={() => deleteToken(item)}
|
|
||||||
>
|
|
||||||
<Trash2 class="mr-2 size-4" />
|
|
||||||
{m.delete()}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Footer class="mt-3">
|
<Dialog.Footer class="mt-3">
|
||||||
<Button onclick={() => (open = false)}>
|
<Button onclick={() => (open = false)}>
|
||||||
|
|||||||
@@ -13,11 +13,9 @@
|
|||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(),
|
open = $bindable()
|
||||||
onTokenCreated
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onTokenCreated?: () => Promise<void>;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
@@ -37,12 +35,11 @@
|
|||||||
|
|
||||||
async function createSignupToken() {
|
async function createSignupToken() {
|
||||||
try {
|
try {
|
||||||
signupToken = await userService.createSignupToken(availableExpirations[selectedExpiration], usageLimit);
|
signupToken = await userService.createSignupToken(
|
||||||
|
availableExpirations[selectedExpiration],
|
||||||
|
usageLimit
|
||||||
|
);
|
||||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||||
|
|
||||||
if (onTokenCreated) {
|
|
||||||
await onTokenCreated();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts" generics="TData extends Record<string, any>">
|
||||||
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||||
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
|
|
||||||
|
let {
|
||||||
|
columns,
|
||||||
|
selectedColumns = $bindable([])
|
||||||
|
}: { columns: AdvancedTableColumn<TData>[]; selectedColumns: string[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class={buttonVariants({
|
||||||
|
variant: 'outline',
|
||||||
|
size: 'sm',
|
||||||
|
class: 'ml-auto h-8'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Settings2Icon />
|
||||||
|
<span class="hidden md:flex">{m.view()}</span>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Label>{m.toggle_columns()}</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
{#each columns as column (column)}
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
closeOnSelect={false}
|
||||||
|
checked={selectedColumns.includes(column.column ?? column.key!)}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const key = column.column ?? column.key!;
|
||||||
|
if (v) {
|
||||||
|
selectedColumns = [...selectedColumns, key];
|
||||||
|
} else {
|
||||||
|
selectedColumns = selectedColumns.filter((c) => c !== key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
128
frontend/src/lib/components/table/advanced-table-filter.svelte
Normal file
128
frontend/src/lib/components/table/advanced-table-filter.svelte
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts" generics="TData, TValue">
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Command from '$lib/components/ui/command/index.js';
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import { cn } from '$lib/utils/style';
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
|
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
selectedValues = new Set<string | boolean>(),
|
||||||
|
showCheckboxes = true,
|
||||||
|
onChanged = (selected: Set<string | boolean>) => {}
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string | boolean;
|
||||||
|
icon?: Component;
|
||||||
|
}[];
|
||||||
|
selectedValues?: Set<string | boolean>;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
onChanged?: (selected: Set<string | boolean>) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 border-dashed"
|
||||||
|
data-testid={`facet-${title.toLowerCase()}-trigger`}
|
||||||
|
>
|
||||||
|
<ListFilterIcon />
|
||||||
|
{title}
|
||||||
|
{#if selectedValues.size > 0}
|
||||||
|
<Separator orientation="vertical" class="mx-2 h-4" />
|
||||||
|
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">
|
||||||
|
{selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
<div class="hidden space-x-1 lg:flex">
|
||||||
|
{#if selectedValues.size > 2}
|
||||||
|
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
|
||||||
|
Count: {selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
{#each options.filter((opt) => selectedValues.has(opt.value)) as option (option)}
|
||||||
|
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content
|
||||||
|
class="w-[200px] p-0"
|
||||||
|
align="start"
|
||||||
|
data-testid={`facet-${title.toLowerCase()}-content`}
|
||||||
|
>
|
||||||
|
<Command.Root>
|
||||||
|
<Command.List>
|
||||||
|
<Command.Empty>{m.no_items_found()}</Command.Empty>
|
||||||
|
<Command.Group>
|
||||||
|
{#each options as option (option)}
|
||||||
|
{@const isSelected = selectedValues.has(option.value)}
|
||||||
|
<Command.Item
|
||||||
|
data-testid={`facet-${title.toLowerCase()}-option-${String(option.value)}`}
|
||||||
|
onSelect={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
selectedValues = new Set([...selectedValues].filter((v) => v !== option.value));
|
||||||
|
} else {
|
||||||
|
selectedValues = new Set([...selectedValues, option.value]);
|
||||||
|
}
|
||||||
|
onChanged(selectedValues);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if showCheckboxes}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'border-primary mr-2 flex size-4 items-center justify-center rounded-sm border',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'opacity-50 [&_svg]:invisible'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon class="size-4" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if option.icon}
|
||||||
|
{@const Icon = option.icon}
|
||||||
|
<Icon class="text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.Group>
|
||||||
|
{#if selectedValues.size > 0}
|
||||||
|
<Command.Separator />
|
||||||
|
<Command.Group>
|
||||||
|
<Command.Item
|
||||||
|
onSelect={() => {
|
||||||
|
selectedValues = new Set();
|
||||||
|
onChanged(selectedValues);
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.clear_filters()}
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
{/if}
|
||||||
|
</Command.List>
|
||||||
|
</Command.Root>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts" generics="TData extends Record<string, any>">
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||||
|
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||||
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
|
import AdvancedTableColumnSelection from './advanced-table-column-selection.svelte';
|
||||||
|
import AdvancedTableFilter from './advanced-table-filter.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
columns,
|
||||||
|
visibleColumns = $bindable(),
|
||||||
|
requestOptions,
|
||||||
|
searchValue = $bindable(),
|
||||||
|
withoutSearch = false,
|
||||||
|
onFilterChange,
|
||||||
|
refresh
|
||||||
|
}: {
|
||||||
|
columns: AdvancedTableColumn<TData>[];
|
||||||
|
visibleColumns: string[];
|
||||||
|
requestOptions: ListRequestOptions;
|
||||||
|
searchValue?: string;
|
||||||
|
withoutSearch?: boolean;
|
||||||
|
onFilterChange?: (selected: Set<string | boolean>, column: string) => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let filterableColumns = $derived(
|
||||||
|
columns
|
||||||
|
.filter((c) => c.filterableValues)
|
||||||
|
.map((c) => ({
|
||||||
|
name: c.label!,
|
||||||
|
column: c.column!,
|
||||||
|
options: c.filterableValues!
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSearch = debounced(async (search: string) => {
|
||||||
|
requestOptions.search = search;
|
||||||
|
await refresh();
|
||||||
|
searchValue = search;
|
||||||
|
}, 300);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<div class="flex flex-1 items-center gap-2 has-[>:nth-child(3)]:flex-wrap">
|
||||||
|
{#if !withoutSearch}
|
||||||
|
<Input
|
||||||
|
value={searchValue}
|
||||||
|
class="relative z-50 w-full sm:max-w-xs"
|
||||||
|
placeholder={m.search()}
|
||||||
|
type="text"
|
||||||
|
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each filterableColumns as col}
|
||||||
|
<AdvancedTableFilter
|
||||||
|
title={col.name}
|
||||||
|
options={col.options}
|
||||||
|
selectedValues={new Set(requestOptions.filters?.[col.column] || [])}
|
||||||
|
onChanged={(selected) => onFilterChange?.(selected, col.column)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<AdvancedTableColumnSelection {columns} bind:selectedColumns={visibleColumns} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
356
frontend/src/lib/components/table/advanced-table.svelte
Normal file
356
frontend/src/lib/components/table/advanced-table.svelte
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<script lang="ts" generics="T extends {id:string}">
|
||||||
|
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||||
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import Empty from '$lib/icons/empty.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
|
import type { ListRequestOptions, Paginated, SortRequest } from '$lib/types/list-request.type';
|
||||||
|
import { cn } from '$lib/utils/style';
|
||||||
|
import { ChevronDown, LucideEllipsis } from '@lucide/svelte';
|
||||||
|
import { PersistedState } from 'runed';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import Button, { buttonVariants } from '../ui/button/button.svelte';
|
||||||
|
import * as DropdownMenu from '../ui/dropdown-menu/index.js';
|
||||||
|
import { Skeleton } from '../ui/skeleton';
|
||||||
|
import AdvancedTableToolbar from './advanced-table-toolbar.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
selectedIds = $bindable(),
|
||||||
|
withoutSearch = false,
|
||||||
|
selectionDisabled = false,
|
||||||
|
fetchCallback,
|
||||||
|
defaultSort,
|
||||||
|
columns,
|
||||||
|
actions
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
selectedIds?: string[];
|
||||||
|
withoutSearch?: boolean;
|
||||||
|
selectionDisabled?: boolean;
|
||||||
|
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
||||||
|
defaultSort?: SortRequest;
|
||||||
|
columns: AdvancedTableColumn<T>[];
|
||||||
|
actions?: CreateAdvancedTableActions<T>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let items: Paginated<T> | undefined = $state();
|
||||||
|
let searchValue = $state('');
|
||||||
|
|
||||||
|
const availablePageSizes: number[] = [20, 50, 100];
|
||||||
|
|
||||||
|
type TablePreferences = {
|
||||||
|
visibleColumns: string[];
|
||||||
|
paginationLimit: number;
|
||||||
|
sort?: SortRequest;
|
||||||
|
filters?: Record<string, (string | boolean)[]>;
|
||||||
|
length?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tablePreferences = new PersistedState<TablePreferences>(`table-${id}-preferences`, {
|
||||||
|
visibleColumns: columns.filter((c) => !c.hidden).map((c) => c.column ?? c.key!),
|
||||||
|
paginationLimit: 20,
|
||||||
|
filters: initializeFilters()
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestOptions = $state<ListRequestOptions>({
|
||||||
|
sort: tablePreferences.current.sort ?? defaultSort,
|
||||||
|
pagination: { limit: tablePreferences.current.paginationLimit, page: 1 },
|
||||||
|
filters: tablePreferences.current.filters
|
||||||
|
});
|
||||||
|
|
||||||
|
let visibleColumns = $derived(
|
||||||
|
columns.filter(
|
||||||
|
(c) => tablePreferences.current.visibleColumns?.includes(c.column ?? c.key!) ?? []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const page = parseInt(urlParams.get(`${id}-page`) ?? '') || undefined;
|
||||||
|
if (page) {
|
||||||
|
requestOptions.pagination!.page = page;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
let allChecked = $derived.by(() => {
|
||||||
|
if (!selectedIds || !items || items.data.length === 0) return false;
|
||||||
|
for (const item of items!.data) {
|
||||||
|
if (!selectedIds.includes(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onAllCheck(checked: boolean) {
|
||||||
|
const pageIds = items!.data.map((item) => item.id);
|
||||||
|
const current = selectedIds ?? [];
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
selectedIds = Array.from(new Set([...current, ...pageIds]));
|
||||||
|
} else {
|
||||||
|
selectedIds = current.filter((id) => !pageIds.includes(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCheck(checked: boolean, id: string) {
|
||||||
|
const current = selectedIds ?? [];
|
||||||
|
if (checked) {
|
||||||
|
selectedIds = Array.from(new Set([...current, id]));
|
||||||
|
} else {
|
||||||
|
selectedIds = current.filter((selectedId) => selectedId !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageChange(page: number) {
|
||||||
|
changePageState(page);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageSizeChange(size: number) {
|
||||||
|
requestOptions.pagination = { limit: size, page: 1 };
|
||||||
|
tablePreferences.current.paginationLimit = size;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFilterChange(selected: Set<string | boolean>, column: string) {
|
||||||
|
requestOptions.filters = {
|
||||||
|
...requestOptions.filters,
|
||||||
|
[column]: Array.from(selected)
|
||||||
|
};
|
||||||
|
tablePreferences.current.filters = requestOptions.filters;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSort(column?: string) {
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
const isSameColumn = requestOptions.sort?.column === column;
|
||||||
|
const nextDirection: 'asc' | 'desc' =
|
||||||
|
isSameColumn && requestOptions.sort?.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
requestOptions.sort = { column, direction: nextDirection };
|
||||||
|
tablePreferences.current.sort = requestOptions.sort;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageState(page: number) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set(`${id}-page`, page.toString());
|
||||||
|
history.replaceState(history.state, '', url.toString());
|
||||||
|
requestOptions.pagination!.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateListLength(totalItems: number) {
|
||||||
|
tablePreferences.current.length =
|
||||||
|
totalItems > tablePreferences.current.paginationLimit
|
||||||
|
? tablePreferences.current.paginationLimit
|
||||||
|
: totalItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeFilters() {
|
||||||
|
const filters: Record<string, (string | boolean)[]> = {};
|
||||||
|
columns.forEach((c) => {
|
||||||
|
if (c.filterableValues) {
|
||||||
|
filters[c.column!] = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
items = await fetchCallback(requestOptions);
|
||||||
|
changePageState(items.pagination.currentPage);
|
||||||
|
updateListLength(items.pagination.totalItems);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AdvancedTableToolbar
|
||||||
|
{columns}
|
||||||
|
bind:visibleColumns={tablePreferences.current.visibleColumns}
|
||||||
|
{requestOptions}
|
||||||
|
{searchValue}
|
||||||
|
{withoutSearch}
|
||||||
|
{refresh}
|
||||||
|
{onFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if (items?.pagination.totalItems === 0 && searchValue === '') || tablePreferences.current.length === 0}
|
||||||
|
<div class="my-5 flex flex-col items-center">
|
||||||
|
<Empty class="text-muted-foreground h-20" />
|
||||||
|
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if !items}
|
||||||
|
<div>
|
||||||
|
{#each Array((tablePreferences.current.length || 10) + 1) as _}
|
||||||
|
<div>
|
||||||
|
<Skeleton class="mt-3 h-[45px] w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div in:fade>
|
||||||
|
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Head class="w-12">
|
||||||
|
<Checkbox
|
||||||
|
disabled={selectionDisabled}
|
||||||
|
checked={allChecked}
|
||||||
|
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
|
||||||
|
/>
|
||||||
|
</Table.Head>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each visibleColumns as column}
|
||||||
|
<Table.Head class={cn(column.sortable && 'p-0')}>
|
||||||
|
{#if column.sortable}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="h-12 w-full justify-start px-4 font-medium hover:bg-transparent"
|
||||||
|
onclick={() => onSort(column.column)}
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
{column.label}
|
||||||
|
<ChevronDown
|
||||||
|
class={cn(
|
||||||
|
'ml-2 size-4 transition-all',
|
||||||
|
requestOptions.sort?.column === column.column
|
||||||
|
? requestOptions.sort?.direction === 'asc'
|
||||||
|
? 'rotate-180 opacity-100'
|
||||||
|
: 'opacity-100'
|
||||||
|
: 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
{column.label}
|
||||||
|
{/if}
|
||||||
|
</Table.Head>
|
||||||
|
{/each}
|
||||||
|
{#if actions}
|
||||||
|
<Table.Head align="right" class="w-12">
|
||||||
|
<span class="sr-only">{m.actions()}</span>
|
||||||
|
</Table.Head>
|
||||||
|
{/if}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items.data as item}
|
||||||
|
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Cell class="w-12">
|
||||||
|
<Checkbox
|
||||||
|
disabled={selectionDisabled}
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
|
{#each visibleColumns as column}
|
||||||
|
<Table.Cell>
|
||||||
|
{#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}
|
||||||
|
</Table.Cell>
|
||||||
|
{/each}
|
||||||
|
{#if actions}
|
||||||
|
<Table.Cell align="right" class="w-12 py-0">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
||||||
|
>
|
||||||
|
<LucideEllipsis class="size-4" />
|
||||||
|
<span class="sr-only">{m.toggle_menu()}</span>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
{#each actions(item).filter((a) => !a.hidden) as action}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => action.onClick(item)}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class={action.variant === 'danger'
|
||||||
|
? 'text-red-500 focus:!text-red-700'
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
|
{#if action.icon}
|
||||||
|
{@const Icon = action.icon}
|
||||||
|
<Icon class="mr-2 size-4" />
|
||||||
|
{/if}
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={items?.pagination.itemsPerPage.toString()}
|
||||||
|
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-9 w-[80px]">
|
||||||
|
{items?.pagination.itemsPerPage}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each availablePageSizes as size}
|
||||||
|
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Pagination.Root
|
||||||
|
class="mx-0 w-auto"
|
||||||
|
count={items?.pagination.totalItems || 0}
|
||||||
|
perPage={items?.pagination.itemsPerPage}
|
||||||
|
{onPageChange}
|
||||||
|
page={items?.pagination.currentPage}
|
||||||
|
>
|
||||||
|
{#snippet children({ pages })}
|
||||||
|
<Pagination.Content class="flex justify-end">
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
{/snippet}
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
7
frontend/src/lib/components/ui/skeleton/index.ts
Normal file
7
frontend/src/lib/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./skeleton.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Skeleton,
|
||||||
|
};
|
||||||
17
frontend/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
17
frontend/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils/style.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="skeleton"
|
||||||
|
class={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...restProps}
|
||||||
|
></div>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||||
import type { UserGroup } from '$lib/types/user-group.type';
|
import type { UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectionDisabled = false,
|
selectionDisabled = false,
|
||||||
@@ -17,30 +15,27 @@
|
|||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
|
|
||||||
let groups: Paginated<UserGroup> | undefined = $state();
|
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [
|
||||||
let requestOptions: SearchPaginationSortRequest = $state({
|
{ label: 'ID', column: 'id', hidden: true },
|
||||||
sort: {
|
{ label: m.friendly_name(), column: 'friendlyName', sortable: true },
|
||||||
column: 'friendlyName',
|
{ label: m.name(), column: 'name', sortable: true },
|
||||||
direction: 'asc'
|
{ label: m.user_count(), column: 'userCount', sortable: true },
|
||||||
}
|
{
|
||||||
});
|
label: m.created(),
|
||||||
|
column: 'createdAt',
|
||||||
onMount(async () => {
|
sortable: true,
|
||||||
groups = await userGroupService.list(requestOptions);
|
hidden: true,
|
||||||
});
|
value: (item) => new Date(item.createdAt).toLocaleString()
|
||||||
|
},
|
||||||
|
{ label: m.ldap_id(), column: 'ldapId', hidden: true }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if groups}
|
<AdvancedTable
|
||||||
<AdvancedTable
|
id="user-group-selection"
|
||||||
items={groups}
|
fetchCallback={userGroupService.list}
|
||||||
{requestOptions}
|
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
|
||||||
onRefresh={async (o) => (groups = await userGroupService.list(o))}
|
bind:selectedIds={selectedGroupIds}
|
||||||
columns={[{ label: m.name(), sortColumn: 'friendlyName' }]}
|
{selectionDisabled}
|
||||||
bind:selectedIds={selectedGroupIds}
|
{columns}
|
||||||
{selectionDisabled}
|
/>
|
||||||
>
|
|
||||||
{#snippet rows({ item })}
|
|
||||||
<Table.Cell>{item.friendlyName}</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type';
|
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';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class ApiKeyService extends APIService {
|
export default class ApiKeyService extends APIService {
|
||||||
async list(options?: SearchPaginationSortRequest) {
|
list = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/api-keys', {
|
const res = await this.api.get('/api-keys', { params: options });
|
||||||
params: options
|
|
||||||
});
|
|
||||||
return res.data as Paginated<ApiKey>;
|
return res.data as Paginated<ApiKey>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async create(data: ApiKeyCreate): Promise<ApiKeyResponse> {
|
create = async (data: ApiKeyCreate): Promise<ApiKeyResponse> => {
|
||||||
const res = await this.api.post('/api-keys', data);
|
const res = await this.api.post('/api-keys', data);
|
||||||
return res.data as ApiKeyResponse;
|
return res.data as ApiKeyResponse;
|
||||||
}
|
};
|
||||||
|
|
||||||
async revoke(id: string): Promise<void> {
|
revoke = async (id: string): Promise<void> => {
|
||||||
await this.api.delete(`/api-keys/${id}`);
|
await this.api.delete(`/api-keys/${id}`);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
abstract class APIService {
|
abstract class APIService {
|
||||||
api = axios.create({
|
protected api = axios.create({ baseURL: '/api' });
|
||||||
baseURL: '/api'
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) {
|
if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) {
|
||||||
this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL;
|
this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default APIService;
|
export default APIService;
|
||||||
|
|||||||
@@ -3,39 +3,33 @@ import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class AppConfigService extends APIService {
|
export default class AppConfigService extends APIService {
|
||||||
async list(showAll = false) {
|
list = async (showAll = false) => {
|
||||||
let url = '/application-configuration';
|
let url = '/application-configuration';
|
||||||
if (showAll) {
|
if (showAll) url += '/all';
|
||||||
url += '/all';
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await this.api.get<AppConfigRawResponse>(url);
|
const { data } = await this.api.get<AppConfigRawResponse>(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
|
// Convert all values to string, stringifying JSON where needed
|
||||||
const appConfigConvertedToString: Record<string, string> = {};
|
const appConfigConvertedToString: Record<string, string> = {};
|
||||||
for (const key in appConfig) {
|
for (const key in appConfig) {
|
||||||
const value = (appConfig as any)[key];
|
const value = (appConfig as any)[key];
|
||||||
if (typeof value === 'object' && value !== null) {
|
appConfigConvertedToString[key] =
|
||||||
appConfigConvertedToString[key] = JSON.stringify(value);
|
typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
||||||
} else {
|
|
||||||
appConfigConvertedToString[key] = String(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', favicon!);
|
formData.append('file', favicon!);
|
||||||
|
|
||||||
await this.api.put(`/application-images/favicon`, formData);
|
await this.api.put(`/application-images/favicon`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLogo(logo: File, light = true) {
|
updateLogo = async (logo: File, light = true) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', logo!);
|
formData.append('file', logo!);
|
||||||
|
|
||||||
@@ -43,52 +37,52 @@ export default class AppConfigService extends APIService {
|
|||||||
params: { light }
|
params: { light }
|
||||||
});
|
});
|
||||||
cachedApplicationLogo.bustCache(light);
|
cachedApplicationLogo.bustCache(light);
|
||||||
}
|
};
|
||||||
|
|
||||||
async updateBackgroundImage(backgroundImage: File) {
|
updateBackgroundImage = async (backgroundImage: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', backgroundImage!);
|
formData.append('file', backgroundImage!);
|
||||||
|
|
||||||
await this.api.put(`/application-images/background`, formData);
|
await this.api.put(`/application-images/background`, formData);
|
||||||
cachedBackgroundImage.bustCache();
|
cachedBackgroundImage.bustCache();
|
||||||
}
|
};
|
||||||
|
|
||||||
async sendTestEmail() {
|
sendTestEmail = async () => {
|
||||||
await this.api.post('/application-configuration/test-email');
|
await this.api.post('/application-configuration/test-email');
|
||||||
}
|
};
|
||||||
|
|
||||||
async syncLdap() {
|
syncLdap = async () => {
|
||||||
await this.api.post('/application-configuration/sync-ldap');
|
await this.api.post('/application-configuration/sync-ldap');
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private parseConfigList(data: AppConfigRawResponse) {
|
function parseConfigList(data: AppConfigRawResponse) {
|
||||||
const appConfig: Partial<AllAppConfig> = {};
|
const appConfig: Partial<AllAppConfig> = {};
|
||||||
data.forEach(({ key, value }) => {
|
data.forEach(({ key, value }) => {
|
||||||
(appConfig as any)[key] = this.parseValue(value);
|
(appConfig as any)[key] = parseValue(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
return appConfig as AllAppConfig;
|
return appConfig as AllAppConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseValue(value: string) {
|
function parseValue(value: string) {
|
||||||
// Try to parse JSON first
|
// Try to parse JSON first
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(value);
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
return parsed;
|
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;
|
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
|
import type { AuditLog } from '$lib/types/audit-log.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';
|
import APIService from './api-service';
|
||||||
|
|
||||||
class AuditLogService extends APIService {
|
export default class AuditLogService extends APIService {
|
||||||
async list(options?: SearchPaginationSortRequest) {
|
list = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/audit-logs', {
|
const res = await this.api.get('/audit-logs', { params: options });
|
||||||
params: options
|
|
||||||
});
|
|
||||||
return res.data as Paginated<AuditLog>;
|
return res.data as Paginated<AuditLog>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async listAllLogs(options?: SearchPaginationSortRequest, filters?: AuditLogFilter) {
|
listAllLogs = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/audit-logs/all', {
|
const res = await this.api.get('/audit-logs/all', { params: options });
|
||||||
params: {
|
|
||||||
...options,
|
|
||||||
filters
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return res.data as Paginated<AuditLog>;
|
return res.data as Paginated<AuditLog>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async listClientNames() {
|
listClientNames = async () => {
|
||||||
const res = await this.api.get<string[]>('/audit-logs/filters/client-names');
|
const res = await this.api.get<string[]>('/audit-logs/filters/client-names');
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
};
|
||||||
|
|
||||||
async listUsers() {
|
listUsers = async () => {
|
||||||
const res = await this.api.get<Record<string, string>>('/audit-logs/filters/users');
|
const res = await this.api.get<Record<string, string>>('/audit-logs/filters/users');
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuditLogService;
|
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import type { CustomClaim } from '$lib/types/custom-claim.type';
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class CustomClaimService extends APIService {
|
export default class CustomClaimService extends APIService {
|
||||||
async getSuggestions() {
|
getSuggestions = async () => {
|
||||||
const res = await this.api.get('/custom-claims/suggestions');
|
const res = await this.api.get('/custom-claims/suggestions');
|
||||||
return res.data as string[];
|
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);
|
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
|
||||||
return res.data as CustomClaim[];
|
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);
|
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
|
||||||
return res.data as CustomClaim[];
|
return res.data as CustomClaim[];
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||||
import type {
|
import type {
|
||||||
AccessibleOidcClient,
|
AccessibleOidcClient,
|
||||||
AuthorizeResponse,
|
AuthorizeResponse,
|
||||||
@@ -9,12 +10,11 @@ import type {
|
|||||||
OidcClientWithAllowedUserGroupsCount,
|
OidcClientWithAllowedUserGroupsCount,
|
||||||
OidcDeviceCodeInfo
|
OidcDeviceCodeInfo
|
||||||
} from '$lib/types/oidc.type';
|
} from '$lib/types/oidc.type';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
|
||||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
class OidcService extends APIService {
|
class OidcService extends APIService {
|
||||||
async authorize(
|
authorize = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
scope: string,
|
scope: string,
|
||||||
callbackURL: string,
|
callbackURL: string,
|
||||||
@@ -22,7 +22,7 @@ class OidcService extends APIService {
|
|||||||
codeChallenge?: string,
|
codeChallenge?: string,
|
||||||
codeChallengeMethod?: string,
|
codeChallengeMethod?: string,
|
||||||
reauthenticationToken?: string
|
reauthenticationToken?: string
|
||||||
) {
|
) => {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -34,45 +34,41 @@ class OidcService extends APIService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
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', {
|
const res = await this.api.post('/oidc/authorization-required', {
|
||||||
scope,
|
scope,
|
||||||
clientId
|
clientId
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data.authorizationRequired as boolean;
|
return res.data.authorizationRequired as boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
async listClients(options?: SearchPaginationSortRequest) {
|
listClients = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/oidc/clients', {
|
const res = await this.api.get('/oidc/clients', {
|
||||||
params: options
|
params: options
|
||||||
});
|
});
|
||||||
return res.data as Paginated<OidcClientWithAllowedUserGroupsCount>;
|
return res.data as Paginated<OidcClientWithAllowedUserGroupsCount>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async createClient(client: OidcClientCreate) {
|
createClient = async (client: OidcClientCreate) =>
|
||||||
return (await this.api.post('/oidc/clients', client)).data as OidcClient;
|
(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}`);
|
await this.api.delete(`/oidc/clients/${id}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
async getClient(id: string) {
|
getClient = async (id: string) =>
|
||||||
return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
|
(await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
|
||||||
}
|
|
||||||
|
|
||||||
async getClientMetaData(id: string) {
|
getClientMetaData = async (id: string) =>
|
||||||
return (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
|
(await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
|
||||||
}
|
|
||||||
|
|
||||||
async updateClient(id: string, client: OidcClientUpdate) {
|
updateClient = async (id: string, client: OidcClientUpdate) =>
|
||||||
return (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
|
(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) {
|
if (client.hasLogo && !image) {
|
||||||
await this.removeClientLogo(client.id);
|
await this.removeClientLogo(client.id);
|
||||||
return;
|
return;
|
||||||
@@ -86,49 +82,45 @@ class OidcService extends APIService {
|
|||||||
|
|
||||||
await this.api.post(`/oidc/clients/${client.id}/logo`, formData);
|
await this.api.post(`/oidc/clients/${client.id}/logo`, formData);
|
||||||
cachedOidcClientLogo.bustCache(client.id);
|
cachedOidcClientLogo.bustCache(client.id);
|
||||||
}
|
};
|
||||||
|
|
||||||
async removeClientLogo(id: string) {
|
removeClientLogo = async (id: string) => {
|
||||||
await this.api.delete(`/oidc/clients/${id}/logo`);
|
await this.api.delete(`/oidc/clients/${id}/logo`);
|
||||||
cachedOidcClientLogo.bustCache(id);
|
cachedOidcClientLogo.bustCache(id);
|
||||||
}
|
};
|
||||||
|
|
||||||
async createClientSecret(id: string) {
|
createClientSecret = async (id: string) =>
|
||||||
return (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as 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 });
|
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
|
||||||
return res.data as OidcClientWithAllowedUserGroups;
|
return res.data as OidcClientWithAllowedUserGroups;
|
||||||
}
|
};
|
||||||
|
|
||||||
async verifyDeviceCode(userCode: string) {
|
verifyDeviceCode = async (userCode: string) => {
|
||||||
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
|
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
async getDeviceCodeInfo(userCode: string): Promise<OidcDeviceCodeInfo> {
|
getDeviceCodeInfo = async (userCode: string): Promise<OidcDeviceCodeInfo> => {
|
||||||
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
|
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
|
||||||
return response.data;
|
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}`, {
|
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
|
||||||
params: { scopes }
|
params: { scopes }
|
||||||
});
|
});
|
||||||
return response.data;
|
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<AccessibleOidcClient>;
|
return res.data as Paginated<AccessibleOidcClient>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async revokeOwnAuthorizedClient(clientId: string) {
|
revokeOwnAuthorizedClient = async (clientId: string) => {
|
||||||
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
|
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OidcService;
|
export default OidcService;
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
UserGroupCreate,
|
UserGroupCreate,
|
||||||
UserGroupWithUserCount,
|
UserGroupWithUserCount,
|
||||||
@@ -7,34 +7,32 @@ import type {
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class UserGroupService extends APIService {
|
export default class UserGroupService extends APIService {
|
||||||
async list(options?: SearchPaginationSortRequest) {
|
list = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/user-groups', {
|
const res = await this.api.get('/user-groups', { params: options });
|
||||||
params: options
|
|
||||||
});
|
|
||||||
return res.data as Paginated<UserGroupWithUserCount>;
|
return res.data as Paginated<UserGroupWithUserCount>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async get(id: string) {
|
get = async (id: string) => {
|
||||||
const res = await this.api.get(`/user-groups/${id}`);
|
const res = await this.api.get(`/user-groups/${id}`);
|
||||||
return res.data as UserGroupWithUsers;
|
return res.data as UserGroupWithUsers;
|
||||||
}
|
};
|
||||||
|
|
||||||
async create(user: UserGroupCreate) {
|
create = async (user: UserGroupCreate) => {
|
||||||
const res = await this.api.post('/user-groups', user);
|
const res = await this.api.post('/user-groups', user);
|
||||||
return res.data as UserGroupWithUsers;
|
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);
|
const res = await this.api.put(`/user-groups/${id}`, user);
|
||||||
return res.data as UserGroupWithUsers;
|
return res.data as UserGroupWithUsers;
|
||||||
}
|
};
|
||||||
|
|
||||||
async remove(id: string) {
|
remove = async (id: string) => {
|
||||||
await this.api.delete(`/user-groups/${id}`);
|
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 });
|
const res = await this.api.put(`/user-groups/${id}/users`, { userIds });
|
||||||
return res.data as UserGroupWithUsers;
|
return res.data as UserGroupWithUsers;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
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 { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||||
import type { UserGroup } from '$lib/types/user-group.type';
|
import type { UserGroup } from '$lib/types/user-group.type';
|
||||||
import type { User, UserCreate, UserSignUp } from '$lib/types/user.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';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class UserService extends APIService {
|
export default class UserService extends APIService {
|
||||||
async list(options?: SearchPaginationSortRequest) {
|
list = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/users', {
|
const res = await this.api.get('/users', { params: options });
|
||||||
params: options
|
|
||||||
});
|
|
||||||
return res.data as Paginated<User>;
|
return res.data as Paginated<User>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async get(id: string) {
|
get = async (id: string) => {
|
||||||
const res = await this.api.get(`/users/${id}`);
|
const res = await this.api.get(`/users/${id}`);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async getCurrent() {
|
getCurrent = async () => {
|
||||||
const res = await this.api.get('/users/me');
|
const res = await this.api.get('/users/me');
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async create(user: UserCreate) {
|
create = async (user: UserCreate) => {
|
||||||
const res = await this.api.post('/users', user);
|
const res = await this.api.post('/users', user);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async getUserGroups(userId: string) {
|
getUserGroups = async (userId: string) => {
|
||||||
const res = await this.api.get(`/users/${userId}/groups`);
|
const res = await this.api.get(`/users/${userId}/groups`);
|
||||||
return res.data as UserGroup[];
|
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);
|
const res = await this.api.put(`/users/${id}`, user);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async updateCurrent(user: UserCreate) {
|
updateCurrent = async (user: UserCreate) => {
|
||||||
const res = await this.api.put('/users/me', user);
|
const res = await this.api.put('/users/me', user);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async remove(id: string) {
|
remove = async (id: string) => {
|
||||||
await this.api.delete(`/users/${id}`);
|
await this.api.delete(`/users/${id}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
async updateProfilePicture(userId: string, image: File) {
|
updateProfilePicture = async (userId: string, image: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', image!);
|
formData.append('file', image!);
|
||||||
|
|
||||||
await this.api.put(`/users/${userId}/profile-picture`, formData);
|
await this.api.put(`/users/${userId}/profile-picture`, formData);
|
||||||
cachedProfilePicture.bustCache(userId);
|
cachedProfilePicture.bustCache(userId);
|
||||||
}
|
};
|
||||||
|
|
||||||
async updateCurrentUsersProfilePicture(image: File) {
|
updateCurrentUsersProfilePicture = async (image: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', image!);
|
formData.append('file', image!);
|
||||||
|
|
||||||
await this.api.put('/users/me/profile-picture', formData);
|
await this.api.put('/users/me/profile-picture', formData);
|
||||||
cachedProfilePicture.bustCache(get(userStore)!.id);
|
cachedProfilePicture.bustCache(get(userStore)!.id);
|
||||||
}
|
};
|
||||||
|
|
||||||
async resetCurrentUserProfilePicture() {
|
resetCurrentUserProfilePicture = async () => {
|
||||||
await this.api.delete(`/users/me/profile-picture`);
|
await this.api.delete(`/users/me/profile-picture`);
|
||||||
cachedProfilePicture.bustCache(get(userStore)!.id);
|
cachedProfilePicture.bustCache(get(userStore)!.id);
|
||||||
}
|
};
|
||||||
|
|
||||||
async resetProfilePicture(userId: string) {
|
resetProfilePicture = async (userId: string) => {
|
||||||
await this.api.delete(`/users/${userId}/profile-picture`);
|
await this.api.delete(`/users/${userId}/profile-picture`);
|
||||||
cachedProfilePicture.bustCache(userId);
|
cachedProfilePicture.bustCache(userId);
|
||||||
}
|
};
|
||||||
|
|
||||||
async createOneTimeAccessToken(userId: string = 'me', ttl?: string|number) {
|
createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => {
|
||||||
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { userId, ttl });
|
||||||
userId,
|
|
||||||
ttl,
|
|
||||||
});
|
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
}
|
};
|
||||||
|
|
||||||
async createSignupToken(ttl: string|number, usageLimit: number) {
|
createSignupToken = async (ttl: string | number, usageLimit: number) => {
|
||||||
const res = await this.api.post(`/signup-tokens`, {
|
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit });
|
||||||
ttl,
|
|
||||||
usageLimit
|
|
||||||
});
|
|
||||||
return res.data.token;
|
return res.data.token;
|
||||||
}
|
};
|
||||||
|
|
||||||
async exchangeOneTimeAccessToken(token: string) {
|
exchangeOneTimeAccessToken = async (token: string) => {
|
||||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||||
return res.data as User;
|
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 });
|
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 });
|
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 });
|
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async signup(data: UserSignUp) {
|
signup = async (data: UserSignUp) => {
|
||||||
const res = await this.api.post(`/signup`, data);
|
const res = await this.api.post(`/signup`, data);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async signupInitialUser(data: UserSignUp) {
|
signupInitialUser = async (data: UserSignUp) => {
|
||||||
const res = await this.api.post(`/signup/setup`, data);
|
const res = await this.api.post(`/signup/setup`, data);
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
};
|
||||||
|
|
||||||
async listSignupTokens(options?: SearchPaginationSortRequest) {
|
listSignupTokens = async (options?: ListRequestOptions) => {
|
||||||
const res = await this.api.get('/signup-tokens', {
|
const res = await this.api.get('/signup-tokens', { params: options });
|
||||||
params: options
|
|
||||||
});
|
|
||||||
return res.data as Paginated<SignupTokenDto>;
|
return res.data as Paginated<SignupTokenDto>;
|
||||||
}
|
};
|
||||||
|
|
||||||
async deleteSignupToken(tokenId: string) {
|
deleteSignupToken = async (tokenId: string) => {
|
||||||
await this.api.delete(`/signup-tokens/${tokenId}`);
|
await this.api.delete(`/signup-tokens/${tokenId}`);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { version as currentVersion } from '$app/environment';
|
import { version as currentVersion } from '$app/environment';
|
||||||
import axios from 'axios';
|
import APIService from './api-service';
|
||||||
|
|
||||||
async function getNewestVersion() {
|
export default class VersionService extends APIService {
|
||||||
const response = await axios
|
getNewestVersion = async () => {
|
||||||
.get('/api/version/latest', {
|
const response = await this.api
|
||||||
timeout: 2000
|
.get('/version/latest', { timeout: 2000 })
|
||||||
})
|
.then((res) => res.data);
|
||||||
.then((res) => res.data);
|
return response.latestVersion;
|
||||||
|
};
|
||||||
|
|
||||||
return response.latestVersion;
|
getCurrentVersion = () => currentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentVersion() {
|
|
||||||
return currentVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
getNewestVersion,
|
|
||||||
getCurrentVersion,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,45 +3,36 @@ import type { User } from '$lib/types/user.type';
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser';
|
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
class WebAuthnService extends APIService {
|
class WebAuthnService extends APIService {
|
||||||
async getRegistrationOptions() {
|
getRegistrationOptions = async () => (await this.api.get(`/webauthn/register/start`)).data;
|
||||||
return (await this.api.get(`/webauthn/register/start`)).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async finishRegistration(body: RegistrationResponseJSON) {
|
finishRegistration = async (body: RegistrationResponseJSON) =>
|
||||||
return (await this.api.post(`/webauthn/register/finish`, body)).data as Passkey;
|
(await this.api.post(`/webauthn/register/finish`, body)).data as Passkey;
|
||||||
}
|
|
||||||
|
|
||||||
async getLoginOptions() {
|
getLoginOptions = async () => (await this.api.get(`/webauthn/login/start`)).data;
|
||||||
return (await this.api.get(`/webauthn/login/start`)).data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async finishLogin(body: AuthenticationResponseJSON) {
|
finishLogin = async (body: AuthenticationResponseJSON) =>
|
||||||
return (await this.api.post(`/webauthn/login/finish`, body)).data as User;
|
(await this.api.post(`/webauthn/login/finish`, body)).data as User;
|
||||||
}
|
|
||||||
|
|
||||||
async logout() {
|
logout = async () => {
|
||||||
await this.api.post(`/webauthn/logout`);
|
await this.api.post(`/webauthn/logout`);
|
||||||
userStore.clearUser();
|
userStore.clearUser();
|
||||||
}
|
};
|
||||||
|
|
||||||
async listCredentials() {
|
listCredentials = async () => (await this.api.get(`/webauthn/credentials`)).data as Passkey[];
|
||||||
return (await this.api.get(`/webauthn/credentials`)).data as Passkey[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeCredential(id: string) {
|
removeCredential = async (id: string) => {
|
||||||
await this.api.delete(`/webauthn/credentials/${id}`);
|
await this.api.delete(`/webauthn/credentials/${id}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
async updateCredentialName(id: string, name: string) {
|
updateCredentialName = async (id: string, name: string) => {
|
||||||
await this.api.patch(`/webauthn/credentials/${id}`, { name });
|
await this.api.patch(`/webauthn/credentials/${id}`, { name });
|
||||||
}
|
};
|
||||||
|
|
||||||
async reauthenticate(body?: AuthenticationResponseJSON) {
|
reauthenticate = async (body?: AuthenticationResponseJSON) => {
|
||||||
const res = await this.api.post('/webauthn/reauthenticate', body);
|
const res = await this.api.post('/webauthn/reauthenticate', body);
|
||||||
return res.data.reauthenticationToken as string;
|
return res.data.reauthenticationToken as string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WebAuthnService;
|
export default WebAuthnService;
|
||||||
|
|||||||
26
frontend/src/lib/types/advanced-table.type.ts
Normal file
26
frontend/src/lib/types/advanced-table.type.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
|
||||||
|
export type AdvancedTableColumn<T extends Record<string, any>> = {
|
||||||
|
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<T extends Record<string, any>> = (item: T) => AdvancedTableAction<T>[];
|
||||||
|
|
||||||
|
export type AdvancedTableAction<T> = {
|
||||||
|
label: string;
|
||||||
|
icon?: Component;
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost';
|
||||||
|
onClick: (item: T) => void;
|
||||||
|
hidden?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
@@ -8,13 +8,11 @@ export type SortRequest = {
|
|||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FilterMap = Record<string, string>;
|
export type ListRequestOptions = {
|
||||||
|
|
||||||
export type SearchPaginationSortRequest = {
|
|
||||||
search?: string;
|
search?: string;
|
||||||
pagination?: PaginationRequest;
|
pagination?: PaginationRequest;
|
||||||
sort?: SortRequest;
|
sort?: SortRequest;
|
||||||
filters?: FilterMap;
|
filters?: Record<string, (string | boolean)[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginationResponse = {
|
export type PaginationResponse = {
|
||||||
@@ -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 { AppVersionInformation } from '$lib/types/application-configuration';
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
export const load: LayoutLoad = async () => {
|
||||||
|
const versionService = new VersionService();
|
||||||
const currentVersion = versionService.getCurrentVersion();
|
const currentVersion = versionService.getCurrentVersion();
|
||||||
|
|
||||||
let newestVersion = null;
|
let newestVersion = null;
|
||||||
|
|||||||
@@ -12,22 +12,16 @@
|
|||||||
import ApiKeyForm from './api-key-form.svelte';
|
import ApiKeyForm from './api-key-form.svelte';
|
||||||
import ApiKeyList from './api-key-list.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();
|
const apiKeyService = new ApiKeyService();
|
||||||
let expandAddApiKey = $state(false);
|
let expandAddApiKey = $state(false);
|
||||||
let apiKeyResponse = $state<ApiKeyResponse | null>(null);
|
let apiKeyResponse = $state<ApiKeyResponse | null>(null);
|
||||||
|
let listRef: ApiKeyList;
|
||||||
|
|
||||||
async function createApiKey(apiKeyData: ApiKeyCreate) {
|
async function createApiKey(apiKeyData: ApiKeyCreate) {
|
||||||
try {
|
try {
|
||||||
const response = await apiKeyService.create(apiKeyData);
|
const response = await apiKeyService.create(apiKeyData);
|
||||||
apiKeyResponse = response;
|
apiKeyResponse = response;
|
||||||
|
listRef.refresh();
|
||||||
// After creation, reload the list of API keys
|
|
||||||
apiKeys = await apiKeyService.list(apiKeysRequestOptions);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
@@ -40,52 +34,46 @@
|
|||||||
<title>{m.api_keys()}</title>
|
<title>{m.api_keys()}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<Card.Root>
|
||||||
<Card.Root>
|
<Card.Header>
|
||||||
<Card.Header>
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<Card.Title>
|
||||||
<Card.Title>
|
<ShieldPlus class="text-primary/80 size-5" />
|
||||||
<ShieldPlus class="text-primary/80 size-5" />
|
{m.create_api_key()}
|
||||||
{m.create_api_key()}
|
</Card.Title>
|
||||||
</Card.Title>
|
<Card.Description
|
||||||
<Card.Description
|
><FormattedMessage m={m.add_a_new_api_key_for_programmatic_access()} /></Card.Description
|
||||||
><FormattedMessage
|
>
|
||||||
m={m.add_a_new_api_key_for_programmatic_access()}
|
|
||||||
/></Card.Description
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if !expandAddApiKey}
|
|
||||||
<Button onclick={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
|
|
||||||
{:else}
|
|
||||||
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddApiKey = false)}>
|
|
||||||
<LucideMinus class="size-5" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
{#if !expandAddApiKey}
|
||||||
{#if expandAddApiKey}
|
<Button onclick={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
|
||||||
<div transition:slide>
|
{:else}
|
||||||
<Card.Content>
|
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddApiKey = false)}>
|
||||||
<ApiKeyForm callback={createApiKey} />
|
<LucideMinus class="size-5" />
|
||||||
</Card.Content>
|
</Button>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</Card.Root>
|
</Card.Header>
|
||||||
</div>
|
{#if expandAddApiKey}
|
||||||
|
<div transition:slide>
|
||||||
|
<Card.Content>
|
||||||
|
<ApiKeyForm callback={createApiKey} />
|
||||||
|
</Card.Content>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
<div>
|
<Card.Root class="gap-0">
|
||||||
<Card.Root>
|
<Card.Header>
|
||||||
<Card.Header>
|
<Card.Title>
|
||||||
<Card.Title>
|
<ShieldEllipsis class="text-primary/80 size-5" />
|
||||||
<ShieldEllipsis class="text-primary/80 size-5" />
|
{m.manage_api_keys()}
|
||||||
{m.manage_api_keys()}
|
</Card.Title>
|
||||||
</Card.Title>
|
</Card.Header>
|
||||||
</Card.Header>
|
<Card.Content>
|
||||||
<Card.Content>
|
<ApiKeyList bind:this={listRef} />
|
||||||
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />
|
</Card.Content>
|
||||||
</Card.Content>
|
</Card.Root>
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ApiKeyDialog bind:apiKeyResponse />
|
<ApiKeyDialog bind:apiKeyResponse />
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -1,31 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import ApiKeyService from '$lib/services/api-key-service';
|
import ApiKeyService from '$lib/services/api-key-service';
|
||||||
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
import type { ApiKey } from '$lib/types/api-key.type';
|
import type { ApiKey } from '$lib/types/api-key.type';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideBan } from '@lucide/svelte';
|
import { LucideBan } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
|
||||||
apiKeys,
|
|
||||||
requestOptions
|
|
||||||
}: {
|
|
||||||
apiKeys: Paginated<ApiKey>;
|
|
||||||
requestOptions: SearchPaginationSortRequest;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
|
let tableRef: AdvancedTable<ApiKey>;
|
||||||
|
|
||||||
|
export function refresh() {
|
||||||
|
return tableRef?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | undefined) {
|
function formatDate(dateStr: string | undefined) {
|
||||||
if (!dateStr) return m.never();
|
if (!dateStr) return m.never();
|
||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<ApiKey>[] = [
|
||||||
|
{ label: m.name(), column: 'name', sortable: true },
|
||||||
|
{
|
||||||
|
label: m.description(),
|
||||||
|
column: 'description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.expires_at(),
|
||||||
|
column: 'expiresAt',
|
||||||
|
sortable: true,
|
||||||
|
value: (item) => formatDate(item.expiresAt)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.last_used(),
|
||||||
|
column: 'lastUsedAt',
|
||||||
|
sortable: true,
|
||||||
|
value: (item) => formatDate(item.lastUsedAt)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.created(),
|
||||||
|
column: 'createdAt',
|
||||||
|
sortable: true,
|
||||||
|
hidden: true,
|
||||||
|
value: (item) => formatDate(item.createdAt)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: CreateAdvancedTableActions<ApiKey> = (apiKey) => [
|
||||||
|
{
|
||||||
|
label: m.revoke(),
|
||||||
|
icon: LucideBan,
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: (apiKey) => revokeApiKey(apiKey)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
function revokeApiKey(apiKey: ApiKey) {
|
function revokeApiKey(apiKey: ApiKey) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
title: m.revoke_api_key(),
|
title: m.revoke_api_key(),
|
||||||
@@ -38,7 +73,7 @@
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await apiKeyService.revoke(apiKey.id);
|
await apiKeyService.revoke(apiKey.id);
|
||||||
apiKeys = await apiKeyService.list(requestOptions);
|
await refresh();
|
||||||
toast.success(m.api_key_revoked_successfully());
|
toast.success(m.api_key_revoked_successfully());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
@@ -50,27 +85,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={apiKeys}
|
id="api-key-list"
|
||||||
{requestOptions}
|
bind:this={tableRef}
|
||||||
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
|
fetchCallback={apiKeyService.list}
|
||||||
|
defaultSort={{ column: 'lastUsedAt', direction: 'desc' }}
|
||||||
withoutSearch
|
withoutSearch
|
||||||
columns={[
|
{columns}
|
||||||
{ label: m.name(), sortColumn: 'name' },
|
{actions}
|
||||||
{ label: m.description() },
|
/>
|
||||||
{ label: m.expires_at(), sortColumn: 'expiresAt' },
|
|
||||||
{ label: m.last_used(), sortColumn: 'lastUsedAt' },
|
|
||||||
{ label: m.actions(), hidden: true }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{#snippet rows({ item })}
|
|
||||||
<Table.Cell>{item.name}</Table.Cell>
|
|
||||||
<Table.Cell class="text-muted-foreground">{item.description || '-'}</Table.Cell>
|
|
||||||
<Table.Cell>{formatDate(item.expiresAt)}</Table.Cell>
|
|
||||||
<Table.Cell>{formatDate(item.lastUsedAt)}</Table.Cell>
|
|
||||||
<Table.Cell class="flex justify-end">
|
|
||||||
<Button onclick={() => revokeApiKey(item)} size="sm" variant="outline" aria-label={m.revoke()}
|
|
||||||
><LucideBan class="size-3 text-red-500" /></Button
|
|
||||||
>
|
|
||||||
</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="logo-light"
|
id="logo-light"
|
||||||
imageClass="size-32"
|
imageClass="size-24"
|
||||||
label={m.light_mode_logo()}
|
label={m.light_mode_logo()}
|
||||||
bind:image={logoLight}
|
bind:image={logoLight}
|
||||||
imageURL={cachedApplicationLogo.getUrl(true)}
|
imageURL={cachedApplicationLogo.getUrl(true)}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="logo-dark"
|
id="logo-dark"
|
||||||
imageClass="size-32"
|
imageClass="size-24"
|
||||||
label={m.dark_mode_logo()}
|
label={m.dark_mode_logo()}
|
||||||
bind:image={logoDark}
|
bind:image={logoDark}
|
||||||
imageURL={cachedApplicationLogo.getUrl(false)}
|
imageURL={cachedApplicationLogo.getUrl(false)}
|
||||||
|
|||||||
@@ -14,9 +14,6 @@
|
|||||||
import OIDCClientForm from './oidc-client-form.svelte';
|
import OIDCClientForm from './oidc-client-form.svelte';
|
||||||
import OIDCClientList from './oidc-client-list.svelte';
|
import OIDCClientList from './oidc-client-list.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
let clients = $state(data.clients);
|
|
||||||
let clientsRequestOptions = $state(data.clientsRequestOptions);
|
|
||||||
let expandAddClient = $state(false);
|
let expandAddClient = $state(false);
|
||||||
|
|
||||||
const oidcService = new OIDCService();
|
const oidcService = new OIDCService();
|
||||||
@@ -86,7 +83,7 @@
|
|||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />
|
<OIDCClientList />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { backNavigate } from '../../users/navigate-back-util';
|
||||||
import OidcForm from '../oidc-client-form.svelte';
|
import OidcForm from '../oidc-client-form.svelte';
|
||||||
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
let showPreview = $state(false);
|
let showPreview = $state(false);
|
||||||
|
|
||||||
const oidcService = new OidcService();
|
const oidcService = new OidcService();
|
||||||
|
const backNavigation = backNavigate('/settings/admin/oidc-clients');
|
||||||
|
|
||||||
const setupDetails = $state({
|
const setupDetails = $state({
|
||||||
[m.authorization_url()]: `https://${page.url.host}/authorize`,
|
[m.authorization_url()]: `https://${page.url.host}/authorize`,
|
||||||
@@ -107,8 +109,8 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
|
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
|
|||||||
@@ -1,27 +1,82 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import { goto } from '$app/navigation';
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
import ImageBox from '$lib/components/image-box.svelte';
|
import ImageBox from '$lib/components/image-box.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import OIDCService from '$lib/services/oidc-service';
|
import OIDCService from '$lib/services/oidc-service';
|
||||||
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
|
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
|
||||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
|
||||||
clients = $bindable(),
|
|
||||||
requestOptions
|
|
||||||
}: {
|
|
||||||
clients: Paginated<OidcClientWithAllowedUserGroupsCount>;
|
|
||||||
requestOptions: SearchPaginationSortRequest;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const oidcService = new OIDCService();
|
const oidcService = new OIDCService();
|
||||||
|
let tableRef: AdvancedTable<OidcClientWithAllowedUserGroupsCount>;
|
||||||
|
|
||||||
|
export function refresh() {
|
||||||
|
return tableRef?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const booleanFilterValues = [
|
||||||
|
{ label: m.enabled(), value: true },
|
||||||
|
{ label: m.disabled(), value: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<OidcClientWithAllowedUserGroupsCount>[] = [
|
||||||
|
{ label: 'ID', column: 'id', hidden: true },
|
||||||
|
{ label: m.logo(), key: 'logo', cell: LogoCell },
|
||||||
|
{ label: m.name(), column: 'name', sortable: true },
|
||||||
|
{
|
||||||
|
label: m.oidc_allowed_group_count(),
|
||||||
|
column: 'allowedUserGroupsCount',
|
||||||
|
sortable: true,
|
||||||
|
value: (item) =>
|
||||||
|
item.allowedUserGroupsCount > 0 ? item.allowedUserGroupsCount : m.unrestricted()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.pkce(),
|
||||||
|
column: 'pkceEnabled',
|
||||||
|
sortable: true,
|
||||||
|
hidden: true,
|
||||||
|
filterableValues: booleanFilterValues
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.reauthentication(),
|
||||||
|
column: 'requiresReauthentication',
|
||||||
|
sortable: true,
|
||||||
|
filterableValues: booleanFilterValues
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.client_launch_url(),
|
||||||
|
column: 'launchURL',
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.public_client(),
|
||||||
|
column: 'isPublic',
|
||||||
|
sortable: true,
|
||||||
|
hidden: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: CreateAdvancedTableActions<OidcClientWithAllowedUserGroupsCount> = (_) => [
|
||||||
|
{
|
||||||
|
label: m.edit(),
|
||||||
|
icon: LucidePencil,
|
||||||
|
onClick: (client) => goto(`/settings/admin/oidc-clients/${client.id}`)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.delete(),
|
||||||
|
icon: LucideTrash,
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: (client) => deleteClient(client)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
async function deleteClient(client: OidcClient) {
|
async function deleteClient(client: OidcClient) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
@@ -33,7 +88,7 @@
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await oidcService.removeClient(client.id);
|
await oidcService.removeClient(client.id);
|
||||||
clients = await oidcService.listClients(requestOptions!);
|
await refresh();
|
||||||
toast.success(m.oidc_client_deleted_successfully());
|
toast.success(m.oidc_client_deleted_successfully());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
@@ -44,48 +99,25 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet LogoCell({ item }: { item: OidcClientWithAllowedUserGroupsCount })}
|
||||||
|
{#if item.hasLogo}
|
||||||
|
<ImageBox
|
||||||
|
class="size-12 rounded-lg"
|
||||||
|
src={cachedOidcClientLogo.getUrl(item.id)}
|
||||||
|
alt={m.name_logo({ name: item.name })}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-muted flex size-12 items-center justify-center rounded-lg text-lg font-bold">
|
||||||
|
{item.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={clients}
|
id="oidc-client-list"
|
||||||
{requestOptions}
|
bind:this={tableRef}
|
||||||
onRefresh={async (o) => (clients = await oidcService.listClients(o))}
|
fetchCallback={oidcService.listClients}
|
||||||
columns={[
|
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||||
{ label: m.logo() },
|
{columns}
|
||||||
{ label: m.name(), sortColumn: 'name' },
|
{actions}
|
||||||
{ label: m.oidc_allowed_group_count(), sortColumn: 'allowedUserGroupsCount' },
|
/>
|
||||||
{ label: m.actions(), hidden: true }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{#snippet rows({ item })}
|
|
||||||
<Table.Cell class="w-8 font-medium">
|
|
||||||
{#if item.hasLogo}
|
|
||||||
<ImageBox
|
|
||||||
class="min-h-8 min-w-8"
|
|
||||||
src={cachedOidcClientLogo.getUrl(item.id)}
|
|
||||||
alt={m.name_logo({ name: item.name })}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="font-medium">{item.name}</Table.Cell>
|
|
||||||
<Table.Cell class="font-medium"
|
|
||||||
>{item.allowedUserGroupsCount > 0
|
|
||||||
? item.allowedUserGroupsCount
|
|
||||||
: m.unrestricted()}</Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell class="align-middle">
|
|
||||||
<div class="flex justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
href="/settings/admin/oidc-clients/{item.id}"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
aria-label={m.edit()}><LucidePencil class="size-3 " /></Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onclick={() => deleteClient(item)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
aria-label={m.delete()}><LucideTrash class="size-3 text-red-500" /></Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
import UserGroupForm from './user-group-form.svelte';
|
import UserGroupForm from './user-group-form.svelte';
|
||||||
import UserGroupList from './user-group-list.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);
|
let expandAddUserGroup = $state(false);
|
||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
@@ -79,7 +76,7 @@
|
|||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />
|
<UserGroupList />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
import CustomClaimService from '$lib/services/custom-claim-service';
|
import CustomClaimService from '$lib/services/custom-claim-service';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
@@ -11,9 +12,9 @@
|
|||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft } from '@lucide/svelte';
|
import { LucideChevronLeft } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { backNavigate } from '../../users/navigate-back-util';
|
||||||
import UserGroupForm from '../user-group-form.svelte';
|
import UserGroupForm from '../user-group-form.svelte';
|
||||||
import UserSelection from '../user-selection.svelte';
|
import UserSelection from '../user-selection.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let userGroup = $state({
|
let userGroup = $state({
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
const customClaimService = new CustomClaimService();
|
const customClaimService = new CustomClaimService();
|
||||||
|
const backNavigation = backNavigate('/settings/admin/user-groups');
|
||||||
|
|
||||||
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -61,8 +63,8 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
|
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||||
>
|
>
|
||||||
{#if !!userGroup.ldapId}
|
{#if !!userGroup.ldapId}
|
||||||
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
||||||
|
|||||||
@@ -1,29 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge/index';
|
import { Badge } from '$lib/components/ui/badge/index';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserGroupService from '$lib/services/user-group-service';
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
|
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||||
import Ellipsis from '@lucide/svelte/icons/ellipsis';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
|
||||||
userGroups,
|
|
||||||
requestOptions
|
|
||||||
}: {
|
|
||||||
userGroups: Paginated<UserGroupWithUserCount>;
|
|
||||||
requestOptions: SearchPaginationSortRequest;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const userGroupService = new UserGroupService();
|
const userGroupService = new UserGroupService();
|
||||||
|
let tableRef: AdvancedTable<UserGroupWithUserCount>;
|
||||||
|
|
||||||
|
export function refresh() {
|
||||||
|
return tableRef?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [
|
||||||
|
{ label: 'ID', column: 'id', hidden: true },
|
||||||
|
{ label: m.friendly_name(), column: 'friendlyName', sortable: true },
|
||||||
|
{ label: m.name(), column: 'name', sortable: true },
|
||||||
|
{ label: m.user_count(), column: 'userCount', sortable: true },
|
||||||
|
{
|
||||||
|
label: m.created(),
|
||||||
|
column: 'createdAt',
|
||||||
|
sortable: true,
|
||||||
|
hidden: true,
|
||||||
|
value: (item) => new Date(item.createdAt).toLocaleString()
|
||||||
|
},
|
||||||
|
{ label: m.ldap_id(), column: 'ldapId', hidden: true },
|
||||||
|
{ label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell }
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: CreateAdvancedTableActions<UserGroupWithUserCount> = (group) => [
|
||||||
|
{
|
||||||
|
label: m.edit(),
|
||||||
|
icon: LucidePencil,
|
||||||
|
variant: 'ghost',
|
||||||
|
onClick: (group) => goto(`/settings/admin/user-groups/${group.id}`)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.delete(),
|
||||||
|
icon: LucideTrash,
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: (group) => deleteUserGroup(group),
|
||||||
|
visible: group.ldapId || $appConfigStore.ldapEnabled
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
async function deleteUserGroup(userGroup: UserGroup) {
|
async function deleteUserGroup(userGroup: UserGroup) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
@@ -35,7 +64,7 @@
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await userGroupService.remove(userGroup.id);
|
await userGroupService.remove(userGroup.id);
|
||||||
userGroups = await userGroupService.list(requestOptions!);
|
await refresh();
|
||||||
toast.success(m.user_group_deleted_successfully());
|
toast.success(m.user_group_deleted_successfully());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
@@ -46,48 +75,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet SourceCell({ item }: { item: UserGroupWithUserCount })}
|
||||||
|
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}>
|
||||||
|
{item.ldapId ? m.ldap() : m.local()}
|
||||||
|
</Badge>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={userGroups}
|
id="user-group-list"
|
||||||
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
|
bind:this={tableRef}
|
||||||
{requestOptions}
|
fetchCallback={userGroupService.list}
|
||||||
columns={[
|
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
|
||||||
{ label: m.friendly_name(), sortColumn: 'friendlyName' },
|
{columns}
|
||||||
{ label: m.name(), sortColumn: 'name' },
|
{actions}
|
||||||
{ label: m.user_count(), sortColumn: 'userCount' },
|
/>
|
||||||
...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
|
|
||||||
{ label: m.actions(), hidden: true }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{#snippet rows({ item })}
|
|
||||||
<Table.Cell>{item.friendlyName}</Table.Cell>
|
|
||||||
<Table.Cell>{item.name}</Table.Cell>
|
|
||||||
<Table.Cell>{item.userCount}</Table.Cell>
|
|
||||||
{#if $appConfigStore.ldapEnabled}
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}
|
|
||||||
>{item.ldapId ? m.ldap() : m.local()}</Badge
|
|
||||||
>
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
<Table.Cell class="flex justify-end">
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<Ellipsis class="size-4" />
|
|
||||||
<span class="sr-only">{m.toggle_menu()}</span>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content align="end">
|
|
||||||
<DropdownMenu.Item onclick={() => goto(`/settings/admin/user-groups/${item.id}`)}
|
|
||||||
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
class="text-red-500 focus:!text-red-700"
|
|
||||||
onclick={() => deleteUserGroup(item)}
|
|
||||||
><LucideTrash class="mr-2 size-4" />{m.delete()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Avatar from '$lib/components/ui/avatar/index';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||||
import type { User } from '$lib/types/user.type';
|
import type { User } from '$lib/types/user.type';
|
||||||
import { onMount } from 'svelte';
|
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectionDisabled = false,
|
selectionDisabled = false,
|
||||||
@@ -17,34 +18,63 @@
|
|||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
let users: Paginated<User> | undefined = $state();
|
const columns: AdvancedTableColumn<User>[] = [
|
||||||
let requestOptions: SearchPaginationSortRequest = $state({
|
{ label: 'ID', column: 'id', hidden: true },
|
||||||
sort: {
|
{ label: m.profile_picture(), key: 'profilePicture', cell: ProfilePictureCell },
|
||||||
column: 'firstName',
|
{ label: m.first_name(), column: 'firstName', sortable: true, hidden: true },
|
||||||
direction: 'asc'
|
{ label: m.last_name(), column: 'lastName', sortable: true, hidden: true },
|
||||||
}
|
{ label: m.display_name(), column: 'displayName', sortable: true },
|
||||||
});
|
{ label: m.email(), column: 'email', sortable: true, hidden: true },
|
||||||
|
{ label: m.username(), column: 'username', sortable: true },
|
||||||
onMount(async () => {
|
{
|
||||||
users = await userService.list(requestOptions);
|
label: m.role(),
|
||||||
});
|
column: 'isAdmin',
|
||||||
|
sortable: true,
|
||||||
|
filterableValues: [
|
||||||
|
{ label: m.admin(), value: true },
|
||||||
|
{ label: m.user(), value: false }
|
||||||
|
],
|
||||||
|
value: (item) => (item.isAdmin ? m.admin() : m.user()),
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.status(),
|
||||||
|
column: 'disabled',
|
||||||
|
cell: StatusCell,
|
||||||
|
sortable: true,
|
||||||
|
filterableValues: [
|
||||||
|
{
|
||||||
|
label: m.enabled(),
|
||||||
|
value: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.disabled(),
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: m.ldap_id(), column: 'ldapId', hidden: true },
|
||||||
|
{ label: m.locale(), column: 'locale', hidden: true }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if users}
|
{#snippet ProfilePictureCell({ item }: { item: User })}
|
||||||
<AdvancedTable
|
<Avatar.Root class="size-8">
|
||||||
items={users}
|
<Avatar.Image class="object-cover" src={cachedProfilePicture.getUrl(item.id)} />
|
||||||
onRefresh={async (o) => (users = await userService.list(o))}
|
</Avatar.Root>
|
||||||
{requestOptions}
|
{/snippet}
|
||||||
columns={[
|
|
||||||
{ label: m.name(), sortColumn: 'firstName' },
|
{#snippet StatusCell({ item }: { item: User })}
|
||||||
{ label: m.email(), sortColumn: 'email' }
|
<Badge class="rounded-full" variant={item.disabled ? 'destructive' : 'default'}>
|
||||||
]}
|
{item.disabled ? m.disabled() : m.enabled()}
|
||||||
bind:selectedIds={selectedUserIds}
|
</Badge>
|
||||||
{selectionDisabled}
|
{/snippet}
|
||||||
>
|
|
||||||
{#snippet rows({ item })}
|
<AdvancedTable
|
||||||
<Table.Cell>{item.displayName}</Table.Cell>
|
id="user-selection"
|
||||||
<Table.Cell>{item.email}</Table.Cell>
|
fetchCallback={userService.list}
|
||||||
{/snippet}
|
defaultSort={{ column: 'firstName', direction: 'asc' }}
|
||||||
</AdvancedTable>
|
bind:selectedIds={selectedUserIds}
|
||||||
{/if}
|
{selectionDisabled}
|
||||||
|
{columns}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -15,17 +15,12 @@
|
|||||||
import UserForm from './user-form.svelte';
|
import UserForm from './user-form.svelte';
|
||||||
import UserList from './user-list.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 selectedCreateOptions = $state(m.add_user());
|
||||||
let expandAddUser = $state(false);
|
let expandAddUser = $state(false);
|
||||||
let signupTokenModalOpen = $state(false);
|
let signupTokenModalOpen = $state(false);
|
||||||
let signupTokenListModalOpen = $state(false);
|
let signupTokenListModalOpen = $state(false);
|
||||||
|
|
||||||
|
let userListRef: UserList;
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
async function createUser(user: UserCreate) {
|
async function createUser(user: UserCreate) {
|
||||||
@@ -38,13 +33,9 @@
|
|||||||
success = false;
|
success = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
users = await userService.list(usersRequestOptions);
|
await userListRef.refresh();
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSignupTokens() {
|
|
||||||
signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -117,15 +108,10 @@
|
|||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<UserList {users} requestOptions={usersRequestOptions} />
|
<UserList bind:this={userListRef} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignupTokenModal bind:open={signupTokenModalOpen} onTokenCreated={refreshSignupTokens} />
|
<SignupTokenModal bind:open={signupTokenModalOpen} />
|
||||||
<SignupTokenListModal
|
<SignupTokenListModal bind:open={signupTokenListModalOpen} />
|
||||||
bind:open={signupTokenListModalOpen}
|
|
||||||
bind:signupTokens
|
|
||||||
{signupTokensRequestOptions}
|
|
||||||
onTokenDeleted={refreshSignupTokens}
|
|
||||||
/>
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft } from '@lucide/svelte';
|
import { LucideChevronLeft } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { backNavigate } from '../navigate-back-util';
|
||||||
import UserForm from '../user-form.svelte';
|
import UserForm from '../user-form.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const customClaimService = new CustomClaimService();
|
const customClaimService = new CustomClaimService();
|
||||||
|
const backNavigation = backNavigate('/settings/admin/users');
|
||||||
|
|
||||||
async function updateUserGroups(userIds: string[]) {
|
async function updateUserGroups(userIds: string[]) {
|
||||||
await userService
|
await userService
|
||||||
@@ -81,8 +83,8 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
|
<button class="text-muted-foreground flex text-sm" onclick={() => backNavigation.go()}
|
||||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||||
>
|
>
|
||||||
{#if !!user.ldapId}
|
{#if !!user.ldapId}
|
||||||
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
|
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
|
||||||
|
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||||
|
import * as Avatar from '$lib/components/ui/avatar/index';
|
||||||
import { Badge } from '$lib/components/ui/badge/index';
|
import { Badge } from '$lib/components/ui/badge/index';
|
||||||
import { buttonVariants } from '$lib/components/ui/button';
|
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
|
||||||
import * as Table from '$lib/components/ui/table';
|
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type {
|
||||||
|
AdvancedTableColumn,
|
||||||
|
CreateAdvancedTableActions
|
||||||
|
} from '$lib/types/advanced-table.type';
|
||||||
import type { User } from '$lib/types/user.type';
|
import type { User } from '$lib/types/user.type';
|
||||||
|
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import {
|
import {
|
||||||
LucideLink,
|
LucideLink,
|
||||||
@@ -21,18 +23,17 @@
|
|||||||
LucideUserCheck,
|
LucideUserCheck,
|
||||||
LucideUserX
|
LucideUserX
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import Ellipsis from '@lucide/svelte/icons/ellipsis';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
|
||||||
users = $bindable(),
|
|
||||||
requestOptions
|
|
||||||
}: { users: Paginated<User>; requestOptions: SearchPaginationSortRequest } = $props();
|
|
||||||
|
|
||||||
let userIdToCreateOneTimeLink: string | null = $state(null);
|
let userIdToCreateOneTimeLink: string | null = $state(null);
|
||||||
|
let tableRef: AdvancedTable<User>;
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
|
export function refresh() {
|
||||||
|
return tableRef?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteUser(user: User) {
|
async function deleteUser(user: User) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
title: m.delete_firstname_lastname({
|
title: m.delete_firstname_lastname({
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await userService.remove(user.id);
|
await userService.remove(user.id);
|
||||||
users = await userService.list(requestOptions!);
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
}
|
}
|
||||||
@@ -62,9 +63,9 @@
|
|||||||
...user,
|
...user,
|
||||||
disabled: false
|
disabled: false
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
toast.success(m.user_enabled_successfully());
|
toast.success(m.user_enabled_successfully());
|
||||||
userService.list(requestOptions!).then((updatedUsers) => (users = updatedUsers));
|
await refresh();
|
||||||
})
|
})
|
||||||
.catch(axiosErrorToast);
|
.catch(axiosErrorToast);
|
||||||
}
|
}
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
...user,
|
...user,
|
||||||
disabled: true
|
disabled: true
|
||||||
});
|
});
|
||||||
users = await userService.list(requestOptions!);
|
await refresh();
|
||||||
toast.success(m.user_disabled_successfully());
|
toast.success(m.user_disabled_successfully());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
@@ -94,85 +95,99 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns: AdvancedTableColumn<User>[] = [
|
||||||
|
{ label: 'ID', column: 'id', hidden: true },
|
||||||
|
{ label: m.profile_picture(), key: 'profilePicture', cell: ProfilePictureCell },
|
||||||
|
{ label: m.first_name(), column: 'firstName', sortable: true },
|
||||||
|
{ label: m.last_name(), column: 'lastName', sortable: true },
|
||||||
|
{ label: m.display_name(), column: 'displayName', sortable: true },
|
||||||
|
{ label: m.email(), column: 'email', sortable: true },
|
||||||
|
{ label: m.username(), column: 'username', sortable: true },
|
||||||
|
{
|
||||||
|
label: m.role(),
|
||||||
|
column: 'isAdmin',
|
||||||
|
sortable: true,
|
||||||
|
filterableValues: [
|
||||||
|
{ label: m.admin(), value: true },
|
||||||
|
{ label: m.user(), value: false }
|
||||||
|
],
|
||||||
|
value: (item) => (item.isAdmin ? m.admin() : m.user())
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.status(),
|
||||||
|
column: 'disabled',
|
||||||
|
cell: StatusCell,
|
||||||
|
sortable: true,
|
||||||
|
filterableValues: [
|
||||||
|
{
|
||||||
|
label: m.enabled(),
|
||||||
|
value: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.disabled(),
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: m.ldap_id(), column: 'ldapId', hidden: true },
|
||||||
|
{ label: m.locale(), column: 'locale', hidden: true },
|
||||||
|
{ label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell }
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: CreateAdvancedTableActions<User> = (u) => [
|
||||||
|
{
|
||||||
|
label: m.login_code(),
|
||||||
|
icon: LucideLink,
|
||||||
|
onClick: (u) => (userIdToCreateOneTimeLink = u.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.edit(),
|
||||||
|
icon: LucidePencil,
|
||||||
|
onClick: (u) => goto(`/settings/admin/users/${u.id}`)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: u.disabled ? m.enable() : m.disable(),
|
||||||
|
icon: u.disabled ? LucideUserCheck : LucideUserX,
|
||||||
|
onClick: (u) => (u.disabled ? enableUser(u) : disableUser(u)),
|
||||||
|
hidden: !!u.ldapId || $appConfigStore.ldapEnabled,
|
||||||
|
disabled: u.id === $userStore?.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: m.delete(),
|
||||||
|
icon: LucideTrash,
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: (u) => deleteUser(u),
|
||||||
|
hidden: !!u.ldapId && !u.disabled,
|
||||||
|
disabled: u.id === $userStore?.id
|
||||||
|
}
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet ProfilePictureCell({ item }: { item: User })}
|
||||||
|
<Avatar.Root class="size-8">
|
||||||
|
<Avatar.Image class="object-cover" src={cachedProfilePicture.getUrl(item.id)} />
|
||||||
|
</Avatar.Root>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet StatusCell({ item }: { item: User })}
|
||||||
|
<Badge class="rounded-full" variant={item.disabled ? 'destructive' : 'default'}>
|
||||||
|
{item.disabled ? m.disabled() : m.enabled()}
|
||||||
|
</Badge>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet SourceCell({ item }: { item: User })}
|
||||||
|
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}>
|
||||||
|
{item.ldapId ? m.ldap() : m.local()}
|
||||||
|
</Badge>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
items={users}
|
id="user-list"
|
||||||
{requestOptions}
|
bind:this={tableRef}
|
||||||
onRefresh={async (options) => (users = await userService.list(options))}
|
fetchCallback={userService.list}
|
||||||
columns={[
|
{actions}
|
||||||
{ label: m.first_name(), sortColumn: 'firstName' },
|
{columns}
|
||||||
{ 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 })}
|
|
||||||
<Table.Cell>{item.firstName}</Table.Cell>
|
|
||||||
<Table.Cell>{item.lastName}</Table.Cell>
|
|
||||||
<Table.Cell>{item.displayName}</Table.Cell>
|
|
||||||
<Table.Cell>{item.email}</Table.Cell>
|
|
||||||
<Table.Cell>{item.username}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge class="rounded-full" variant="outline">{item.isAdmin ? m.admin() : m.user()}</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge class="rounded-full" variant={item.disabled ? 'destructive' : 'default'}>
|
|
||||||
{item.disabled ? m.disabled() : m.enabled()}
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
{#if $appConfigStore.ldapEnabled}
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}
|
|
||||||
>{item.ldapId ? m.ldap() : m.local()}</Badge
|
|
||||||
>
|
|
||||||
</Table.Cell>
|
|
||||||
{/if}
|
|
||||||
<Table.Cell>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
|
||||||
<Ellipsis class="size-4" />
|
|
||||||
<span class="sr-only">{m.toggle_menu()}</span>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content align="end">
|
|
||||||
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
|
|
||||||
><LucideLink class="mr-2 size-4" />{m.login_code()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
|
|
||||||
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
|
|
||||||
{#if item.disabled}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={item.id === $userStore?.id}
|
|
||||||
onclick={() => enableUser(item)}
|
|
||||||
><LucideUserCheck class="mr-2 size-4" />{m.enable()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={item.id === $userStore?.id}
|
|
||||||
onclick={() => disableUser(item)}
|
|
||||||
><LucideUserX class="mr-2 size-4" />{m.disable()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if !item.ldapId || (item.ldapId && item.disabled)}
|
|
||||||
<DropdownMenu.Item
|
|
||||||
class="text-red-500 focus:!text-red-700"
|
|
||||||
disabled={item.id === $userStore?.id}
|
|
||||||
onclick={() => deleteUser(item)}
|
|
||||||
><LucideTrash class="mr-2 size-4" />{m.delete()}</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
{/snippet}
|
|
||||||
</AdvancedTable>
|
|
||||||
|
|
||||||
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />
|
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import * as Pagination from '$lib/components/ui/pagination';
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import OIDCService from '$lib/services/oidc-service';
|
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 { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LayoutDashboard } from '@lucide/svelte';
|
import { LayoutDashboard } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -12,11 +12,11 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
|
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
|
||||||
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
|
let requestOptions: ListRequestOptions = $state(data.appRequestOptions);
|
||||||
|
|
||||||
const oidcService = new OIDCService();
|
const oidcService = new OIDCService();
|
||||||
|
|
||||||
async function onRefresh(options: SearchPaginationSortRequest) {
|
async function onRefresh(options: ListRequestOptions) {
|
||||||
clients = await oidcService.listOwnAccessibleClients(options);
|
clients = await oidcService.listOwnAccessibleClients(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,10 @@
|
|||||||
{#each clients.data as client}
|
{#each clients.data as client}
|
||||||
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- Gap fix if two elements are present-->
|
||||||
|
{#if clients.data.length == 2}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if clients.pagination.totalPages > 1}
|
{#if clients.pagination.totalPages > 1}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import OIDCService from '$lib/services/oidc-service';
|
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';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
const oidcService = new OIDCService();
|
const oidcService = new OIDCService();
|
||||||
|
|
||||||
const appRequestOptions: SearchPaginationSortRequest = {
|
const appRequestOptions: ListRequestOptions = {
|
||||||
pagination: {
|
pagination: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20
|
limit: 20
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="aspect-square h-[56px]">
|
<div class="aspect-square h-[56px]">
|
||||||
<ImageBox
|
<ImageBox
|
||||||
class="size-8"
|
class="size-14"
|
||||||
src={client.hasLogo
|
src={client.hasLogo
|
||||||
? cachedOidcClientLogo.getUrl(client.id)
|
? cachedOidcClientLogo.getUrl(client.id)
|
||||||
: cachedApplicationLogo.getUrl(isLightMode)}
|
: cachedApplicationLogo.getUrl(isLightMode)}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import { LogsIcon } from '@lucide/svelte';
|
import { LogsIcon } from '@lucide/svelte';
|
||||||
import AuditLogSwitcher from './audit-log-switcher.svelte';
|
import AuditLogSwitcher from './audit-log-switcher.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
let auditLogsRequestOptions = $state(data.auditLogsRequestOptions);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -19,7 +16,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Card.Root>
|
<Card.Root class="gap-0">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>
|
<Card.Title>
|
||||||
<LogsIcon class="text-primary/80 size-5" />
|
<LogsIcon class="text-primary/80 size-5" />
|
||||||
@@ -28,7 +25,7 @@
|
|||||||
<Card.Description>{m.see_your_account_activities_from_the_last_3_months()}</Card.Description>
|
<Card.Description>{m.see_your_account_activities_from_the_last_3_months()}</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<AuditLogList auditLogs={data.auditLogs} requestOptions={auditLogsRequestOptions} />
|
<AuditLogList />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -6,15 +6,11 @@
|
|||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import AuditLogService from '$lib/services/audit-log-service';
|
import AuditLogService from '$lib/services/audit-log-service';
|
||||||
import type { AuditLogFilter } from '$lib/types/audit-log.type';
|
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 AuditLogSwitcher from '../audit-log-switcher.svelte';
|
||||||
import {eventTypes as eventTranslations} from "$lib/utils/audit-log-translator";
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
const auditLogService = new AuditLogService();
|
const auditLogService = new AuditLogService();
|
||||||
|
let auditLogListRef: AuditLogList;
|
||||||
let auditLogs = $state(data.auditLogs);
|
|
||||||
let requestOptions = $state(data.requestOptions);
|
|
||||||
|
|
||||||
let filters: AuditLogFilter = $state({
|
let filters: AuditLogFilter = $state({
|
||||||
userId: '',
|
userId: '',
|
||||||
@@ -29,10 +25,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eventTypes = $state(eventTranslations);
|
const eventTypes = $state(eventTranslations);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
auditLogService.listAllLogs(requestOptions, filters).then((response) => (auditLogs = response));
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -124,7 +116,6 @@
|
|||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AuditLogList bind:this={auditLogListRef} isAdmin {filters} />
|
||||||
<AuditLogList isAdmin={true} {auditLogs} {requestOptions} />
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -18,4 +18,4 @@ services:
|
|||||||
args:
|
args:
|
||||||
- BUILD_TAGS=e2etest
|
- BUILD_TAGS=e2etest
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ test.describe('API Key Management', () => {
|
|||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('row', { name: apiKey.name })
|
.getByRole('row', { name: apiKey.name })
|
||||||
.getByRole('button', { name: 'Revoke' })
|
.getByRole('button', { name: 'Toggle menu' })
|
||||||
.click();
|
.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
|
// Verify success message
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');
|
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,14 @@ test('Delete OIDC client', async ({ page }) => {
|
|||||||
const oidcClient = oidcClients.nextcloud;
|
const oidcClient = oidcClients.nextcloud;
|
||||||
await page.goto('/settings/admin/oidc-clients');
|
await page.goto('/settings/admin/oidc-clients');
|
||||||
|
|
||||||
await page.getByRole('row', { name: oidcClient.name }).getByLabel('Delete').click();
|
await page
|
||||||
await page.getByText('Delete', { exact: true }).click();
|
.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(
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
'OIDC client deleted successfully'
|
'OIDC client deleted successfully'
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ test('Update user group users', async ({ page }) => {
|
|||||||
const group = userGroups.designers;
|
const group = userGroups.designers;
|
||||||
await page.goto(`/settings/admin/user-groups/${group.id}`);
|
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.tim.username }).getByRole('checkbox').click();
|
||||||
await page.getByRole('row', { name: users.craig.email }).getByRole('checkbox').click();
|
await page.getByRole('row', { name: users.craig.username }).getByRole('checkbox').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).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 page.reload();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('row', { name: users.tim.email }).getByRole('checkbox')
|
page.getByRole('row', { name: users.tim.username }).getByRole('checkbox')
|
||||||
).toHaveAttribute('data-state', 'unchecked');
|
).toHaveAttribute('data-state', 'unchecked');
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('row', { name: users.craig.email }).getByRole('checkbox')
|
page.getByRole('row', { name: users.craig.username }).getByRole('checkbox')
|
||||||
).toHaveAttribute('data-state', 'checked');
|
).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.getByLabel('Remove custom claim').first().click();
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(2).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'
|
'Custom claims updated successfully'
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Check if custom claim is removed
|
// Check if custom claim is removed
|
||||||
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
|
||||||
|
|||||||
Reference in New Issue
Block a user