feat: add various improvements to the table component (#961)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
Elias Schneider
2025-10-13 11:12:55 +02:00
committed by GitHub
parent 24ca6a106d
commit c20e93b55c
76 changed files with 1948 additions and 1434 deletions

View File

@@ -45,15 +45,11 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(ctx)
userID := ctx.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = ctx.Error(err)
return
}
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = ctx.Error(err)
return

View File

@@ -41,18 +41,12 @@ type AuditLogController struct {
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
err := c.ShouldBindQuery(&sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -86,26 +80,12 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[userId] query string false "Filter by user ID"
// @Param filters[event] query string false "Filter by event type"
// @Param filters[clientName] query string false "Filter by client name"
// @Param filters[location] query string false "Filter by location type (external or internal)"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
var filters dto.AuditLogFilterDto
if err := c.ShouldBindQuery(&filters); err != nil {
_ = c.Error(err)
return
}
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return

View File

@@ -403,13 +403,9 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -685,12 +681,9 @@ func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -741,15 +734,11 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return

View File

@@ -104,13 +104,9 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -574,13 +570,9 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return

View File

@@ -47,16 +47,10 @@ type UserGroupController struct {
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
ctx := c.Request.Context()
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -70,7 +64,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
_ = c.Error(err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
if err != nil {
_ = c.Error(err)
return

View File

@@ -17,10 +17,3 @@ type AuditLogDto struct {
Username string `json:"username"`
Data map[string]string `json:"data"`
}
type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"`
Location string `form:"filters[location]"`
}

View File

@@ -9,7 +9,7 @@ import (
type AuditLog struct {
Base
Event AuditLogEvent `sortable:"true"`
Event AuditLogEvent `sortable:"true" filterable:"true"`
IpAddress *string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
@@ -17,7 +17,7 @@ type AuditLog struct {
Username string `gorm:"-"`
Data AuditLogData
UserID string
UserID string `filterable:"true"`
User User
}

View File

@@ -53,8 +53,8 @@ type OidcClient struct {
LogoutCallbackURLs UrlList
ImageType *string
IsPublic bool
PkceEnabled bool
RequiresReauthentication bool
PkceEnabled bool `filterable:"true"`
RequiresReauthentication bool `filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string

View File

@@ -18,10 +18,10 @@ type User struct {
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
Disabled bool `sortable:"true" filterable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`

View File

@@ -25,14 +25,14 @@ func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db, emailService: emailService}
}
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&model.ApiKey{})
var apiKeys []model.ApiKey
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, err
}

View File

@@ -6,7 +6,6 @@ import (
"log/slog"
userAgentParser "github.com/mileusna/useragent"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
@@ -136,14 +135,14 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ?", userID)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
return logs, pagination, err
}
@@ -152,7 +151,7 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}
func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest, filters dto.AuditLogFilterDto) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
@@ -160,33 +159,36 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
Preload("User").
Model(&model.AuditLog{})
if filters.UserID != "" {
query = query.Where("user_id = ?", filters.UserID)
}
if filters.Event != "" {
query = query.Where("event = ?", filters.Event)
}
if filters.ClientName != "" {
if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
dialect := s.db.Name()
switch dialect {
case "sqlite":
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
case "postgres":
query = query.Where("data->>'clientName' = ?", filters.ClientName)
query = query.Where("data->>'clientName' IN ?", clientName)
default:
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
}
}
if filters.Location != "" {
switch filters.Location {
case "external":
query = query.Where("country != 'Internal Network'")
case "internal":
query = query.Where("country = 'Internal Network'")
if locations, ok := listRequestOptions.Filters["location"]; ok {
mapped := make([]string, 0, len(locations))
for _, v := range locations {
if s, ok := v.(string); ok {
switch s {
case "internal":
mapped = append(mapped, "Internal Network")
case "external":
mapped = append(mapped, "External Network")
}
}
}
if len(mapped) > 0 {
query = query.Where("country IN ?", mapped)
}
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
if err != nil {
return nil, pagination, err
}

View File

@@ -692,7 +692,7 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
return client, nil
}
func (s *OidcService) ListClients(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
func (s *OidcService) ListClients(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.
@@ -705,17 +705,17 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
}
// As allowedUserGroupsCount is not a column, we need to manually sort it
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id").
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + sortedPaginationRequest.Sort.Direction)
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &clients)
response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients)
return clients, response, err
}
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
return clients, response, err
}
@@ -1350,7 +1350,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
return count, nil
}
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
@@ -1359,7 +1359,7 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
Where("user_id = ?", userID)
var authorizedClients []model.UserAuthorizedOidcClient
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &authorizedClients)
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
return authorizedClients, response, err
}
@@ -1392,7 +1392,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return nil
}
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1439,13 +1439,13 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction + " NULLS LAST")
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}

View File

@@ -21,7 +21,7 @@ func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserG
return &UserGroupService{db: db, appConfigService: appConfigService}
}
func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.
WithContext(ctx).
Preload("CustomClaims").
@@ -32,17 +32,14 @@ func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginati
}
// As userCount is not a column we need to manually sort it
if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
return groups, response, err
Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
return groups, response, err
}

View File

@@ -46,7 +46,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
}
}
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.WithContext(ctx).
Model(&model.User{}).
@@ -60,7 +60,7 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
searchPattern, searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
return users, pagination, err
}
@@ -794,11 +794,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
return user, accessToken, nil
}
func (s *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
}

View 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
}

View File

@@ -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"
}

View File

@@ -81,26 +81,21 @@ func CapitalizeFirstLetter(str string) string {
return result.String()
}
func CamelCaseToSnakeCase(str string) string {
result := strings.Builder{}
result.Grow(int(float32(len(str)) * 1.1))
for i, r := range str {
if unicode.IsUpper(r) && i > 0 {
result.WriteByte('_')
}
result.WriteRune(unicode.ToLower(r))
}
return result.String()
var (
reAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`) // ABCd -> AB_Cd
reLowerToUpper = regexp.MustCompile(`([a-z0-9])([A-Z])`) // aB -> a_B
)
func CamelCaseToSnakeCase(s string) string {
s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
return strings.ToLower(s)
}
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
func CamelCaseToScreamingSnakeCase(s string) string {
// Insert underscores before uppercase letters (except the first one)
snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
// Convert to uppercase
return strings.ToUpper(snake)
s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
return strings.ToUpper(s)
}
// GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode

View File

@@ -86,9 +86,9 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
{"simple camelCase", "camelCase", "camel_case"},
{"PascalCase", "PascalCase", "pascal_case"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
{"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"},
{"consecutive uppercase", "HTTPRequest", "http_request"},
{"single lowercase word", "word", "word"},
{"single uppercase word", "WORD", "w_o_r_d"},
{"single uppercase word", "WORD", "word"},
{"with numbers", "camel123Case", "camel123_case"},
{"with numbers in middle", "model2Name", "model2_name"},
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
@@ -104,6 +104,34 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
}
}
func TestCamelCaseToScreamingSnakeCase(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"simple camelCase", "camelCase", "CAMEL_CASE"},
{"PascalCase", "PascalCase", "PASCAL_CASE"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "MULTIPLE_WORDS_IN_CAMEL_CASE"},
{"consecutive uppercase", "HTTPRequest", "HTTP_REQUEST"},
{"single lowercase word", "word", "WORD"},
{"single uppercase word", "WORD", "WORD"},
{"with numbers", "camel123Case", "CAMEL123_CASE"},
{"with numbers in middle", "model2Name", "MODEL2_NAME"},
{"mixed case", "iPhone6sPlus", "I_PHONE6S_PLUS"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CamelCaseToScreamingSnakeCase(tt.input)
if result != tt.expected {
t.Errorf("CamelCaseToScreamingSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestGetFirstCharacter(t *testing.T) {
tests := []struct {
name string

View 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
}

View 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)
}
}
}

View File

@@ -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>.",
"invalid_url": "Invalid URL",
"require_user_email": "Require Email Address",
"require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address."
"require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.",
"view": "View",
"toggle_columns": "Toggle columns",
"locale": "Locale",
"ldap_id" : "LDAP ID",
"reauthentication": "Re-authentication",
"clear_filters" : "Clear Filters"
}

View File

@@ -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}

View File

@@ -1,69 +1,111 @@
<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 * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import {translateAuditLogEvent} from "$lib/utils/audit-log-translator";
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
import { translateAuditLogEvent } from '$lib/utils/audit-log-translator';
let {
auditLogs,
isAdmin = false,
requestOptions
filters
}: {
auditLogs: Paginated<AuditLog>;
isAdmin?: boolean;
requestOptions: SearchPaginationSortRequest;
filters?: AuditLogFilter;
} = $props();
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>
{#snippet EventCell({ item }: { item: AuditLog })}
<Badge class="rounded-full" variant="outline">
{translateAuditLogEvent(item.event)}
</Badge>
{/snippet}
<AdvancedTable
items={auditLogs}
{requestOptions}
onRefresh={async (options) =>
id="audit-log-list-{isAdmin ? 'admin' : 'user'}"
bind:this={tableRef}
fetchCallback={async (options) =>
isAdmin
? (auditLogs = await auditLogService.listAllLogs(options))
: (auditLogs = await auditLogService.list(options))}
columns={[
{ label: m.time(), sortColumn: 'createdAt' },
...(isAdmin ? [{ label: 'Username' }] : []),
{ label: m.event(), sortColumn: 'event' },
{ label: m.approximate_location(), sortColumn: 'city' },
{ label: m.ip_address(), sortColumn: 'ipAddress' },
{ label: m.device(), sortColumn: 'device' },
{ label: m.client() }
]}
? await auditLogService.listAllLogs({
...options,
filters: wrapFilters(filters)
})
: await auditLogService.list(options)}
defaultSort={{ column: 'createdAt', direction: 'desc' }}
withoutSearch
>
{#snippet rows({ item })}
<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>
{columns}
/>

View File

@@ -12,14 +12,10 @@
});
</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}
<LucideImageOff class={cn('text-muted-foreground p-5', props.class)} />
<LucideImageOff class="text-muted-foreground p-5" />
{:else}
<img
{...props}
class={cn('object-contain aspect-square', props.class)}
onerror={() => (error = true)}
/>
<img {...props} class="aspect-square object-contain" onerror={() => (error = true)} />
{/if}
</div>

View File

@@ -1,33 +1,29 @@
<script lang="ts">
import { page } from '$app/state';
import AdvancedTable from '$lib/components/advanced-table.svelte';
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 { Button, buttonVariants } from '$lib/components/ui/button';
import { Button } from '$lib/components/ui/button';
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 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 { axiosErrorToast } from '$lib/utils/error-util';
import { Copy, Ellipsis, Trash2 } from '@lucide/svelte';
import { Copy, Trash2 } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
let {
open = $bindable(),
signupTokens = $bindable(),
signupTokensRequestOptions,
onTokenDeleted
open = $bindable()
}: {
open: boolean;
signupTokens: Paginated<SignupTokenDto>;
signupTokensRequestOptions: SearchPaginationSortRequest;
onTokenDeleted?: () => Promise<void>;
} = $props();
const userService = new UserService();
let tableRef: AdvancedTable<SignupTokenDto>;
function formatDate(dateStr: string | undefined) {
if (!dateStr) return m.never();
@@ -44,12 +40,8 @@
action: async () => {
try {
await userService.deleteSignupToken(token.id);
await tableRef.refresh();
toast.success(m.signup_token_deleted_successfully());
// Refresh the tokens
if (onTokenDeleted) {
await onTokenDeleted();
}
} catch (e) {
axiosErrorToast(e);
}
@@ -98,8 +90,69 @@
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>
{#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.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
<Dialog.Header>
@@ -111,70 +164,13 @@
<div class="flex-1 overflow-hidden">
<AdvancedTable
items={signupTokens}
requestOptions={signupTokensRequestOptions}
id="signup-token-list"
withoutSearch={true}
onRefresh={async (options) => {
const result = await userService.listSignupTokens(options);
signupTokens = result;
return result;
}}
columns={[
{ label: m.token() },
{ label: m.status() },
{ label: m.usage(), sortColumn: 'usageCount' },
{ label: m.expires(), sortColumn: 'expiresAt' },
{ label: m.created(), sortColumn: 'createdAt' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
<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>
fetchCallback={userService.listSignupTokens}
bind:this={tableRef}
{columns}
{actions}
/>
</div>
<Dialog.Footer class="mt-3">
<Button onclick={() => (open = false)}>

View File

@@ -13,11 +13,9 @@
import { mode } from 'mode-watcher';
let {
open = $bindable(),
onTokenCreated
open = $bindable()
}: {
open: boolean;
onTokenCreated?: () => Promise<void>;
} = $props();
const userService = new UserService();
@@ -37,12 +35,11 @@
async function createSignupToken() {
try {
signupToken = await userService.createSignupToken(availableExpirations[selectedExpiration], usageLimit);
signupToken = await userService.createSignupToken(
availableExpirations[selectedExpiration],
usageLimit
);
signupLink = `${page.url.origin}/st/${signupToken}`;
if (onTokenCreated) {
await onTokenCreated();
}
} catch (e) {
axiosErrorToast(e);
}

View File

@@ -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>

View 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>

View File

@@ -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>

View 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}

View File

@@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};

View 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>

View File

@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="table-cell"
class={cn(
'p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
'py-3 px-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...restProps}

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
import { onMount } from 'svelte';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { UserGroupWithUserCount } from '$lib/types/user-group.type';
let {
selectionDisabled = false,
@@ -17,30 +15,27 @@
const userGroupService = new UserGroupService();
let groups: Paginated<UserGroup> | undefined = $state();
let requestOptions: SearchPaginationSortRequest = $state({
sort: {
column: 'friendlyName',
direction: 'asc'
}
});
onMount(async () => {
groups = await userGroupService.list(requestOptions);
});
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 }
];
</script>
{#if groups}
<AdvancedTable
items={groups}
{requestOptions}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: m.name(), sortColumn: 'friendlyName' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>
{#snippet rows({ item })}
<Table.Cell>{item.friendlyName}</Table.Cell>
{/snippet}
</AdvancedTable>
{/if}
<AdvancedTable
id="user-group-selection"
fetchCallback={userGroupService.list}
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
{columns}
/>

View File

@@ -1,21 +1,19 @@
import type { ApiKey, ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import APIService from './api-service';
export default class ApiKeyService extends APIService {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/api-keys', {
params: options
});
list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/api-keys', { params: options });
return res.data as Paginated<ApiKey>;
}
};
async create(data: ApiKeyCreate): Promise<ApiKeyResponse> {
create = async (data: ApiKeyCreate): Promise<ApiKeyResponse> => {
const res = await this.api.post('/api-keys', data);
return res.data as ApiKeyResponse;
}
};
async revoke(id: string): Promise<void> {
revoke = async (id: string): Promise<void> => {
await this.api.delete(`/api-keys/${id}`);
}
};
}

View File

@@ -1,15 +1,13 @@
import axios from 'axios';
abstract class APIService {
api = axios.create({
baseURL: '/api'
});
protected api = axios.create({ baseURL: '/api' });
constructor() {
if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) {
this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL;
}
}
constructor() {
if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) {
this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL;
}
}
}
export default APIService;

View File

@@ -3,39 +3,33 @@ import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-
import APIService from './api-service';
export default class AppConfigService extends APIService {
async list(showAll = false) {
list = async (showAll = false) => {
let url = '/application-configuration';
if (showAll) {
url += '/all';
}
if (showAll) url += '/all';
const { data } = await this.api.get<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
const appConfigConvertedToString: Record<string, string> = {};
for (const key in appConfig) {
const value = (appConfig as any)[key];
if (typeof value === 'object' && value !== null) {
appConfigConvertedToString[key] = JSON.stringify(value);
} else {
appConfigConvertedToString[key] = String(value);
}
appConfigConvertedToString[key] =
typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
}
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
return this.parseConfigList(res.data);
}
return parseConfigList(res.data);
};
async updateFavicon(favicon: File) {
updateFavicon = async (favicon: File) => {
const formData = new FormData();
formData.append('file', favicon!);
await this.api.put(`/application-images/favicon`, formData);
}
async updateLogo(logo: File, light = true) {
updateLogo = async (logo: File, light = true) => {
const formData = new FormData();
formData.append('file', logo!);
@@ -43,52 +37,52 @@ export default class AppConfigService extends APIService {
params: { light }
});
cachedApplicationLogo.bustCache(light);
}
};
async updateBackgroundImage(backgroundImage: File) {
updateBackgroundImage = async (backgroundImage: File) => {
const formData = new FormData();
formData.append('file', backgroundImage!);
await this.api.put(`/application-images/background`, formData);
cachedBackgroundImage.bustCache();
}
};
async sendTestEmail() {
sendTestEmail = async () => {
await this.api.post('/application-configuration/test-email');
}
};
async syncLdap() {
syncLdap = async () => {
await this.api.post('/application-configuration/sync-ldap');
}
};
}
private parseConfigList(data: AppConfigRawResponse) {
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(appConfig as any)[key] = this.parseValue(value);
});
function parseConfigList(data: AppConfigRawResponse) {
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(appConfig as any)[key] = parseValue(value);
});
return appConfig as AllAppConfig;
}
return appConfig as AllAppConfig;
}
private parseValue(value: string) {
// Try to parse JSON first
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
value = String(parsed);
} catch {}
// Handle rest of the types
if (value === 'true') {
return true;
} else if (value === 'false') {
return false;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
return parseFloat(value);
} else {
return value;
function parseValue(value: string) {
// Try to parse JSON first
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
value = String(parsed);
} catch {}
// Handle rest of the types
if (value === 'true') {
return true;
} else if (value === 'false') {
return false;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
return parseFloat(value);
} else {
return value;
}
}

View File

@@ -1,34 +1,25 @@
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import APIService from './api-service';
class AuditLogService extends APIService {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/audit-logs', {
params: options
});
export default class AuditLogService extends APIService {
list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/audit-logs', { params: options });
return res.data as Paginated<AuditLog>;
}
};
async listAllLogs(options?: SearchPaginationSortRequest, filters?: AuditLogFilter) {
const res = await this.api.get('/audit-logs/all', {
params: {
...options,
filters
}
});
listAllLogs = async (options?: ListRequestOptions) => {
const res = await this.api.get('/audit-logs/all', { params: options });
return res.data as Paginated<AuditLog>;
}
};
async listClientNames() {
listClientNames = async () => {
const res = await this.api.get<string[]>('/audit-logs/filters/client-names');
return res.data;
}
};
async listUsers() {
listUsers = async () => {
const res = await this.api.get<Record<string, string>>('/audit-logs/filters/users');
return res.data;
}
};
}
export default AuditLogService;

View File

@@ -2,18 +2,18 @@ import type { CustomClaim } from '$lib/types/custom-claim.type';
import APIService from './api-service';
export default class CustomClaimService extends APIService {
async getSuggestions() {
getSuggestions = async () => {
const res = await this.api.get('/custom-claims/suggestions');
return res.data as string[];
}
};
async updateUserCustomClaims(userId: string, claims: CustomClaim[]) {
updateUserCustomClaims = async (userId: string, claims: CustomClaim[]) => {
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
return res.data as CustomClaim[];
}
};
async updateUserGroupCustomClaims(userGroupId: string, claims: CustomClaim[]) {
updateUserGroupCustomClaims = async (userGroupId: string, claims: CustomClaim[]) => {
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
return res.data as CustomClaim[];
}
};
}

View File

@@ -1,3 +1,4 @@
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type {
AccessibleOidcClient,
AuthorizeResponse,
@@ -9,12 +10,11 @@ import type {
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo
} from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import APIService from './api-service';
class OidcService extends APIService {
async authorize(
authorize = async (
clientId: string,
scope: string,
callbackURL: string,
@@ -22,7 +22,7 @@ class OidcService extends APIService {
codeChallenge?: string,
codeChallengeMethod?: string,
reauthenticationToken?: string
) {
) => {
const res = await this.api.post('/oidc/authorize', {
scope,
nonce,
@@ -34,45 +34,41 @@ class OidcService extends APIService {
});
return res.data as AuthorizeResponse;
}
};
async isAuthorizationRequired(clientId: string, scope: string) {
isAuthorizationRequired = async (clientId: string, scope: string) => {
const res = await this.api.post('/oidc/authorization-required', {
scope,
clientId
});
return res.data.authorizationRequired as boolean;
}
};
async listClients(options?: SearchPaginationSortRequest) {
listClients = async (options?: ListRequestOptions) => {
const res = await this.api.get('/oidc/clients', {
params: options
});
return res.data as Paginated<OidcClientWithAllowedUserGroupsCount>;
}
};
async createClient(client: OidcClientCreate) {
return (await this.api.post('/oidc/clients', client)).data as OidcClient;
}
createClient = async (client: OidcClientCreate) =>
(await this.api.post('/oidc/clients', client)).data as OidcClient;
async removeClient(id: string) {
removeClient = async (id: string) => {
await this.api.delete(`/oidc/clients/${id}`);
}
};
async getClient(id: string) {
return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
}
getClient = async (id: string) =>
(await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
async getClientMetaData(id: string) {
return (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
}
getClientMetaData = async (id: string) =>
(await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
async updateClient(id: string, client: OidcClientUpdate) {
return (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
}
updateClient = async (id: string, client: OidcClientUpdate) =>
(await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
async updateClientLogo(client: OidcClient, image: File | null) {
updateClientLogo = async (client: OidcClient, image: File | null) => {
if (client.hasLogo && !image) {
await this.removeClientLogo(client.id);
return;
@@ -86,49 +82,45 @@ class OidcService extends APIService {
await this.api.post(`/oidc/clients/${client.id}/logo`, formData);
cachedOidcClientLogo.bustCache(client.id);
}
};
async removeClientLogo(id: string) {
removeClientLogo = async (id: string) => {
await this.api.delete(`/oidc/clients/${id}/logo`);
cachedOidcClientLogo.bustCache(id);
}
};
async createClientSecret(id: string) {
return (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string;
}
createClientSecret = async (id: string) =>
(await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string;
async updateAllowedUserGroups(id: string, userGroupIds: string[]) {
updateAllowedUserGroups = async (id: string, userGroupIds: string[]) => {
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
return res.data as OidcClientWithAllowedUserGroups;
}
};
async verifyDeviceCode(userCode: string) {
verifyDeviceCode = async (userCode: string) => {
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
}
};
async getDeviceCodeInfo(userCode: string): Promise<OidcDeviceCodeInfo> {
getDeviceCodeInfo = async (userCode: string): Promise<OidcDeviceCodeInfo> => {
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
return response.data;
}
};
async getClientPreview(id: string, userId: string, scopes: string) {
getClientPreview = async (id: string, userId: string, scopes: string) => {
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
params: { scopes }
});
return response.data;
}
async listOwnAccessibleClients(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/oidc/users/me/clients', {
params: options
});
};
listOwnAccessibleClients = async (options?: ListRequestOptions) => {
const res = await this.api.get('/oidc/users/me/clients', { params: options });
return res.data as Paginated<AccessibleOidcClient>;
}
};
async revokeOwnAuthorizedClient(clientId: string) {
revokeOwnAuthorizedClient = async (clientId: string) => {
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
}
};
}
export default OidcService;

View File

@@ -1,4 +1,4 @@
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type {
UserGroupCreate,
UserGroupWithUserCount,
@@ -7,34 +7,32 @@ import type {
import APIService from './api-service';
export default class UserGroupService extends APIService {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/user-groups', {
params: options
});
list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/user-groups', { params: options });
return res.data as Paginated<UserGroupWithUserCount>;
}
};
async get(id: string) {
get = async (id: string) => {
const res = await this.api.get(`/user-groups/${id}`);
return res.data as UserGroupWithUsers;
}
};
async create(user: UserGroupCreate) {
create = async (user: UserGroupCreate) => {
const res = await this.api.post('/user-groups', user);
return res.data as UserGroupWithUsers;
}
};
async update(id: string, user: UserGroupCreate) {
update = async (id: string, user: UserGroupCreate) => {
const res = await this.api.put(`/user-groups/${id}`, user);
return res.data as UserGroupWithUsers;
}
};
async remove(id: string) {
remove = async (id: string) => {
await this.api.delete(`/user-groups/${id}`);
}
};
async updateUsers(id: string, userIds: string[]) {
updateUsers = async (id: string, userIds: string[]) => {
const res = await this.api.put(`/user-groups/${id}/users`, { userIds });
return res.data as UserGroupWithUsers;
}
};
}

View File

@@ -1,5 +1,5 @@
import userStore from '$lib/stores/user-store';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { SignupTokenDto } from '$lib/types/signup-token.type';
import type { UserGroup } from '$lib/types/user-group.type';
import type { User, UserCreate, UserSignUp } from '$lib/types/user.type';
@@ -8,125 +8,113 @@ import { get } from 'svelte/store';
import APIService from './api-service';
export default class UserService extends APIService {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/users', {
params: options
});
list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/users', { params: options });
return res.data as Paginated<User>;
}
};
async get(id: string) {
get = async (id: string) => {
const res = await this.api.get(`/users/${id}`);
return res.data as User;
}
};
async getCurrent() {
getCurrent = async () => {
const res = await this.api.get('/users/me');
return res.data as User;
}
};
async create(user: UserCreate) {
create = async (user: UserCreate) => {
const res = await this.api.post('/users', user);
return res.data as User;
}
};
async getUserGroups(userId: string) {
getUserGroups = async (userId: string) => {
const res = await this.api.get(`/users/${userId}/groups`);
return res.data as UserGroup[];
}
};
async update(id: string, user: UserCreate) {
update = async (id: string, user: UserCreate) => {
const res = await this.api.put(`/users/${id}`, user);
return res.data as User;
}
};
async updateCurrent(user: UserCreate) {
updateCurrent = async (user: UserCreate) => {
const res = await this.api.put('/users/me', user);
return res.data as User;
}
};
async remove(id: string) {
remove = async (id: string) => {
await this.api.delete(`/users/${id}`);
}
};
async updateProfilePicture(userId: string, image: File) {
updateProfilePicture = async (userId: string, image: File) => {
const formData = new FormData();
formData.append('file', image!);
await this.api.put(`/users/${userId}/profile-picture`, formData);
cachedProfilePicture.bustCache(userId);
}
};
async updateCurrentUsersProfilePicture(image: File) {
updateCurrentUsersProfilePicture = async (image: File) => {
const formData = new FormData();
formData.append('file', image!);
await this.api.put('/users/me/profile-picture', formData);
cachedProfilePicture.bustCache(get(userStore)!.id);
}
};
async resetCurrentUserProfilePicture() {
resetCurrentUserProfilePicture = async () => {
await this.api.delete(`/users/me/profile-picture`);
cachedProfilePicture.bustCache(get(userStore)!.id);
}
};
async resetProfilePicture(userId: string) {
resetProfilePicture = async (userId: string) => {
await this.api.delete(`/users/${userId}/profile-picture`);
cachedProfilePicture.bustCache(userId);
}
};
async createOneTimeAccessToken(userId: string = 'me', ttl?: string|number) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId,
ttl,
});
createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { userId, ttl });
return res.data.token;
}
};
async createSignupToken(ttl: string|number, usageLimit: number) {
const res = await this.api.post(`/signup-tokens`, {
ttl,
usageLimit
});
createSignupToken = async (ttl: string | number, usageLimit: number) => {
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit });
return res.data.token;
}
};
async exchangeOneTimeAccessToken(token: string) {
exchangeOneTimeAccessToken = async (token: string) => {
const res = await this.api.post(`/one-time-access-token/${token}`);
return res.data as User;
}
};
async requestOneTimeAccessEmailAsUnauthenticatedUser(email: string, redirectPath?: string) {
requestOneTimeAccessEmailAsUnauthenticatedUser = async (email: string, redirectPath?: string) => {
await this.api.post('/one-time-access-email', { email, redirectPath });
}
};
async requestOneTimeAccessEmailAsAdmin(userId: string, ttl: string|number) {
requestOneTimeAccessEmailAsAdmin = async (userId: string, ttl: string | number) => {
await this.api.post(`/users/${userId}/one-time-access-email`, { ttl });
}
};
async updateUserGroups(id: string, userGroupIds: string[]) {
updateUserGroups = async (id: string, userGroupIds: string[]) => {
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
return res.data as User;
}
};
async signup(data: UserSignUp) {
signup = async (data: UserSignUp) => {
const res = await this.api.post(`/signup`, data);
return res.data as User;
}
};
async signupInitialUser(data: UserSignUp) {
signupInitialUser = async (data: UserSignUp) => {
const res = await this.api.post(`/signup/setup`, data);
return res.data as User;
}
};
async listSignupTokens(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/signup-tokens', {
params: options
});
listSignupTokens = async (options?: ListRequestOptions) => {
const res = await this.api.get('/signup-tokens', { params: options });
return res.data as Paginated<SignupTokenDto>;
}
};
async deleteSignupToken(tokenId: string) {
deleteSignupToken = async (tokenId: string) => {
await this.api.delete(`/signup-tokens/${tokenId}`);
}
};
}

View File

@@ -1,21 +1,13 @@
import { version as currentVersion } from '$app/environment';
import axios from 'axios';
import APIService from './api-service';
async function getNewestVersion() {
const response = await axios
.get('/api/version/latest', {
timeout: 2000
})
.then((res) => res.data);
export default class VersionService extends APIService {
getNewestVersion = async () => {
const response = await this.api
.get('/version/latest', { timeout: 2000 })
.then((res) => res.data);
return response.latestVersion;
};
return response.latestVersion;
getCurrentVersion = () => currentVersion;
}
function getCurrentVersion() {
return currentVersion;
}
export default {
getNewestVersion,
getCurrentVersion,
};

View File

@@ -3,45 +3,36 @@ import type { User } from '$lib/types/user.type';
import APIService from './api-service';
import userStore from '$lib/stores/user-store';
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser';
class WebAuthnService extends APIService {
async getRegistrationOptions() {
return (await this.api.get(`/webauthn/register/start`)).data;
}
getRegistrationOptions = async () => (await this.api.get(`/webauthn/register/start`)).data;
async finishRegistration(body: RegistrationResponseJSON) {
return (await this.api.post(`/webauthn/register/finish`, body)).data as Passkey;
}
finishRegistration = async (body: RegistrationResponseJSON) =>
(await this.api.post(`/webauthn/register/finish`, body)).data as Passkey;
async getLoginOptions() {
return (await this.api.get(`/webauthn/login/start`)).data;
}
getLoginOptions = async () => (await this.api.get(`/webauthn/login/start`)).data;
async finishLogin(body: AuthenticationResponseJSON) {
return (await this.api.post(`/webauthn/login/finish`, body)).data as User;
}
finishLogin = async (body: AuthenticationResponseJSON) =>
(await this.api.post(`/webauthn/login/finish`, body)).data as User;
async logout() {
await this.api.post(`/webauthn/logout`);
userStore.clearUser();
}
logout = async () => {
await this.api.post(`/webauthn/logout`);
userStore.clearUser();
};
async listCredentials() {
return (await this.api.get(`/webauthn/credentials`)).data as Passkey[];
}
listCredentials = async () => (await this.api.get(`/webauthn/credentials`)).data as Passkey[];
async removeCredential(id: string) {
await this.api.delete(`/webauthn/credentials/${id}`);
}
removeCredential = async (id: string) => {
await this.api.delete(`/webauthn/credentials/${id}`);
};
async updateCredentialName(id: string, name: string) {
await this.api.patch(`/webauthn/credentials/${id}`, { name });
}
updateCredentialName = async (id: string, name: string) => {
await this.api.patch(`/webauthn/credentials/${id}`, { name });
};
async reauthenticate(body?: AuthenticationResponseJSON) {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
}
reauthenticate = async (body?: AuthenticationResponseJSON) => {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
};
}
export default WebAuthnService;

View 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;
};

View File

@@ -8,13 +8,11 @@ export type SortRequest = {
direction: 'asc' | 'desc';
};
export type FilterMap = Record<string, string>;
export type SearchPaginationSortRequest = {
export type ListRequestOptions = {
search?: string;
pagination?: PaginationRequest;
sort?: SortRequest;
filters?: FilterMap;
filters?: Record<string, (string | boolean)[]>;
};
export type PaginationResponse = {

View File

@@ -1,8 +1,9 @@
import versionService from '$lib/services/version-service';
import VersionService from '$lib/services/version-service';
import type { AppVersionInformation } from '$lib/types/application-configuration';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async () => {
const versionService = new VersionService();
const currentVersion = versionService.getCurrentVersion();
let newestVersion = null;

View File

@@ -12,22 +12,16 @@
import ApiKeyForm from './api-key-form.svelte';
import ApiKeyList from './api-key-list.svelte';
let { data } = $props();
let apiKeys = $state(data.apiKeys);
let apiKeysRequestOptions = $state(data.apiKeysRequestOptions);
const apiKeyService = new ApiKeyService();
let expandAddApiKey = $state(false);
let apiKeyResponse = $state<ApiKeyResponse | null>(null);
let listRef: ApiKeyList;
async function createApiKey(apiKeyData: ApiKeyCreate) {
try {
const response = await apiKeyService.create(apiKeyData);
apiKeyResponse = response;
// After creation, reload the list of API keys
apiKeys = await apiKeyService.list(apiKeysRequestOptions);
listRef.refresh();
return true;
} catch (e) {
axiosErrorToast(e);
@@ -40,52 +34,46 @@
<title>{m.api_keys()}</title>
</svelte:head>
<div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<ShieldPlus class="text-primary/80 size-5" />
{m.create_api_key()}
</Card.Title>
<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}
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<ShieldPlus class="text-primary/80 size-5" />
{m.create_api_key()}
</Card.Title>
<Card.Description
><FormattedMessage m={m.add_a_new_api_key_for_programmatic_access()} /></Card.Description
>
</div>
</Card.Header>
{#if expandAddApiKey}
<div transition:slide>
<Card.Content>
<ApiKeyForm callback={createApiKey} />
</Card.Content>
</div>
{/if}
</Card.Root>
</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>
</Card.Header>
{#if expandAddApiKey}
<div transition:slide>
<Card.Content>
<ApiKeyForm callback={createApiKey} />
</Card.Content>
</div>
{/if}
</Card.Root>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<ShieldEllipsis class="text-primary/80 size-5" />
{m.manage_api_keys()}
</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />
</Card.Content>
</Card.Root>
</div>
<Card.Root class="gap-0">
<Card.Header>
<Card.Title>
<ShieldEllipsis class="text-primary/80 size-5" />
{m.manage_api_keys()}
</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList bind:this={listRef} />
</Card.Content>
</Card.Root>
<ApiKeyDialog bind:apiKeyResponse />

View File

@@ -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 };
};

View File

@@ -1,31 +1,66 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { m } from '$lib/paraglide/messages';
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 { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideBan } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
let {
apiKeys,
requestOptions
}: {
apiKeys: Paginated<ApiKey>;
requestOptions: SearchPaginationSortRequest;
} = $props();
const apiKeyService = new ApiKeyService();
let tableRef: AdvancedTable<ApiKey>;
export function refresh() {
return tableRef?.refresh();
}
function formatDate(dateStr: string | undefined) {
if (!dateStr) return m.never();
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) {
openConfirmDialog({
title: m.revoke_api_key(),
@@ -38,7 +73,7 @@
action: async () => {
try {
await apiKeyService.revoke(apiKey.id);
apiKeys = await apiKeyService.list(requestOptions);
await refresh();
toast.success(m.api_key_revoked_successfully());
} catch (e) {
axiosErrorToast(e);
@@ -50,27 +85,11 @@
</script>
<AdvancedTable
items={apiKeys}
{requestOptions}
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
id="api-key-list"
bind:this={tableRef}
fetchCallback={apiKeyService.list}
defaultSort={{ column: 'lastUsedAt', direction: 'desc' }}
withoutSearch
columns={[
{ label: m.name(), sortColumn: 'name' },
{ label: m.description() },
{ label: m.expires_at(), sortColumn: 'expiresAt' },
{ label: m.last_used(), sortColumn: 'lastUsedAt' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
<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>
{columns}
{actions}
/>

View File

@@ -32,7 +32,7 @@
/>
<ApplicationImage
id="logo-light"
imageClass="size-32"
imageClass="size-24"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL={cachedApplicationLogo.getUrl(true)}
@@ -40,7 +40,7 @@
/>
<ApplicationImage
id="logo-dark"
imageClass="size-32"
imageClass="size-24"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL={cachedApplicationLogo.getUrl(false)}

View File

@@ -14,9 +14,6 @@
import OIDCClientForm from './oidc-client-form.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);
const oidcService = new OIDCService();
@@ -86,7 +83,7 @@
</Card.Title>
</Card.Header>
<Card.Content>
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />
<OIDCClientList />
</Card.Content>
</Card.Root>
</div>

View File

@@ -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 };
};

View File

@@ -16,6 +16,7 @@
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { backNavigate } from '../../users/navigate-back-util';
import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
@@ -28,6 +29,7 @@
let showPreview = $state(false);
const oidcService = new OidcService();
const backNavigation = backNavigate('/settings/admin/oidc-clients');
const setupDetails = $state({
[m.authorization_url()]: `https://${page.url.host}/authorize`,
@@ -107,8 +109,8 @@
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="size-5" /> {m.back()}</a
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
><LucideChevronLeft class="size-5" /> {m.back()}</button
>
</div>
<Card.Root>

View File

@@ -1,27 +1,82 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { goto } from '$app/navigation';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import ImageBox from '$lib/components/image-box.svelte';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { m } from '$lib/paraglide/messages';
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 { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
let {
clients = $bindable(),
requestOptions
}: {
clients: Paginated<OidcClientWithAllowedUserGroupsCount>;
requestOptions: SearchPaginationSortRequest;
} = $props();
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) {
openConfirmDialog({
@@ -33,7 +88,7 @@
action: async () => {
try {
await oidcService.removeClient(client.id);
clients = await oidcService.listClients(requestOptions!);
await refresh();
toast.success(m.oidc_client_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
@@ -44,48 +99,25 @@
}
</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
items={clients}
{requestOptions}
onRefresh={async (o) => (clients = await oidcService.listClients(o))}
columns={[
{ label: m.logo() },
{ label: m.name(), sortColumn: 'name' },
{ label: m.oidc_allowed_group_count(), sortColumn: 'allowedUserGroupsCount' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
<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>
id="oidc-client-list"
bind:this={tableRef}
fetchCallback={oidcService.listClients}
defaultSort={{ column: 'name', direction: 'asc' }}
{columns}
{actions}
/>

View File

@@ -12,9 +12,6 @@
import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte';
let { data } = $props();
let userGroups = $state(data.userGroups);
let userGroupsRequestOptions = $state(data.userGroupsRequestOptions);
let expandAddUserGroup = $state(false);
const userGroupService = new UserGroupService();
@@ -79,7 +76,7 @@
</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />
<UserGroupList />
</Card.Content>
</Card.Root>
</div>

View File

@@ -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 };
};

View File

@@ -4,6 +4,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
@@ -11,9 +12,9 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../../users/navigate-back-util';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroup = $state({
@@ -23,6 +24,7 @@
const userGroupService = new UserGroupService();
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/user-groups');
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true;
@@ -61,8 +63,8 @@
</svelte:head>
<div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="size-5" /> {m.back()}</a
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
><LucideChevronLeft class="size-5" /> {m.back()}</button
>
{#if !!userGroup.ldapId}
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>

View File

@@ -1,29 +1,58 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdvancedTable from '$lib/components/advanced-table.svelte';
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 * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
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 { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import { toast } from 'svelte-sonner';
let {
userGroups,
requestOptions
}: {
userGroups: Paginated<UserGroupWithUserCount>;
requestOptions: SearchPaginationSortRequest;
} = $props();
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) {
openConfirmDialog({
@@ -35,7 +64,7 @@
action: async () => {
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list(requestOptions!);
await refresh();
toast.success(m.user_group_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
@@ -46,48 +75,17 @@
}
</script>
{#snippet SourceCell({ item }: { item: UserGroupWithUserCount })}
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}>
{item.ldapId ? m.ldap() : m.local()}
</Badge>
{/snippet}
<AdvancedTable
items={userGroups}
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
{requestOptions}
columns={[
{ label: m.friendly_name(), sortColumn: 'friendlyName' },
{ label: m.name(), sortColumn: 'name' },
{ label: m.user_count(), sortColumn: 'userCount' },
...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
<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>
id="user-group-list"
bind:this={tableRef}
fetchCallback={userGroupService.list}
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
{columns}
{actions}
/>

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
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';
import { m } from '$lib/paraglide/messages';
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 { onMount } from 'svelte';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
let {
selectionDisabled = false,
@@ -17,34 +18,63 @@
const userService = new UserService();
let users: Paginated<User> | undefined = $state();
let requestOptions: SearchPaginationSortRequest = $state({
sort: {
column: 'firstName',
direction: 'asc'
}
});
onMount(async () => {
users = await userService.list(requestOptions);
});
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, hidden: true },
{ 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 },
{
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>
{#if users}
<AdvancedTable
items={users}
onRefresh={async (o) => (users = await userService.list(o))}
{requestOptions}
columns={[
{ label: m.name(), sortColumn: 'firstName' },
{ label: m.email(), sortColumn: 'email' }
]}
bind:selectedIds={selectedUserIds}
{selectionDisabled}
>
{#snippet rows({ item })}
<Table.Cell>{item.displayName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell>
{/snippet}
</AdvancedTable>
{/if}
{#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}
<AdvancedTable
id="user-selection"
fetchCallback={userService.list}
defaultSort={{ column: 'firstName', direction: 'asc' }}
bind:selectedIds={selectedUserIds}
{selectionDisabled}
{columns}
/>

View File

@@ -15,17 +15,12 @@
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
let { data } = $props();
let users = $state(data.users);
let usersRequestOptions = $state(data.usersRequestOptions);
let signupTokens = $state(data.signupTokens);
let signupTokensRequestOptions = $state(data.signupTokensRequestOptions);
let selectedCreateOptions = $state(m.add_user());
let expandAddUser = $state(false);
let signupTokenModalOpen = $state(false);
let signupTokenListModalOpen = $state(false);
let userListRef: UserList;
const userService = new UserService();
async function createUser(user: UserCreate) {
@@ -38,13 +33,9 @@
success = false;
});
users = await userService.list(usersRequestOptions);
await userListRef.refresh();
return success;
}
async function refreshSignupTokens() {
signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
}
</script>
<svelte:head>
@@ -117,15 +108,10 @@
</Card.Title>
</Card.Header>
<Card.Content>
<UserList {users} requestOptions={usersRequestOptions} />
<UserList bind:this={userListRef} />
</Card.Content>
</Card.Root>
</div>
<SignupTokenModal bind:open={signupTokenModalOpen} onTokenCreated={refreshSignupTokens} />
<SignupTokenListModal
bind:open={signupTokenListModalOpen}
bind:signupTokens
{signupTokensRequestOptions}
onTokenDeleted={refreshSignupTokens}
/>
<SignupTokenModal bind:open={signupTokenModalOpen} />
<SignupTokenListModal bind:open={signupTokenListModalOpen} />

View File

@@ -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
};
};

View File

@@ -14,6 +14,7 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../navigate-back-util';
import UserForm from '../user-form.svelte';
let { data } = $props();
@@ -24,6 +25,7 @@
const userService = new UserService();
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/users');
async function updateUserGroups(userIds: string[]) {
await userService
@@ -81,8 +83,8 @@
</svelte:head>
<div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="size-5" /> {m.back()}</a
<button class="text-muted-foreground flex text-sm" onclick={() => backNavigation.go()}
><LucideChevronLeft class="size-5" /> {m.back()}</button
>
{#if !!user.ldapId}
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>

View File

@@ -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);
}
}
};
};

View File

@@ -1,18 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
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 { 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 UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-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 { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import {
LucideLink,
@@ -21,18 +23,17 @@
LucideUserCheck,
LucideUserX
} from '@lucide/svelte';
import Ellipsis from '@lucide/svelte/icons/ellipsis';
import { toast } from 'svelte-sonner';
let {
users = $bindable(),
requestOptions
}: { users: Paginated<User>; requestOptions: SearchPaginationSortRequest } = $props();
let userIdToCreateOneTimeLink: string | null = $state(null);
let tableRef: AdvancedTable<User>;
const userService = new UserService();
export function refresh() {
return tableRef?.refresh();
}
async function deleteUser(user: User) {
openConfirmDialog({
title: m.delete_firstname_lastname({
@@ -46,7 +47,7 @@
action: async () => {
try {
await userService.remove(user.id);
users = await userService.list(requestOptions!);
await refresh();
} catch (e) {
axiosErrorToast(e);
}
@@ -62,9 +63,9 @@
...user,
disabled: false
})
.then(() => {
.then(async () => {
toast.success(m.user_enabled_successfully());
userService.list(requestOptions!).then((updatedUsers) => (users = updatedUsers));
await refresh();
})
.catch(axiosErrorToast);
}
@@ -85,7 +86,7 @@
...user,
disabled: true
});
users = await userService.list(requestOptions!);
await refresh();
toast.success(m.user_disabled_successfully());
} catch (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>
{#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
items={users}
{requestOptions}
onRefresh={async (options) => (users = await userService.list(options))}
columns={[
{ label: m.first_name(), sortColumn: 'firstName' },
{ label: m.last_name(), sortColumn: 'lastName' },
{ label: m.display_name(), sortColumn: 'displayName' },
{ label: m.email(), sortColumn: 'email' },
{ label: m.username(), sortColumn: 'username' },
{ label: m.role(), sortColumn: 'isAdmin' },
{ label: m.status(), sortColumn: 'disabled' },
...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
<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>
id="user-list"
bind:this={tableRef}
fetchCallback={userService.list}
{actions}
{columns}
/>
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />

View File

@@ -3,8 +3,8 @@
import * as Pagination from '$lib/components/ui/pagination';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LayoutDashboard } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
@@ -12,11 +12,11 @@
let { data } = $props();
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
let requestOptions: ListRequestOptions = $state(data.appRequestOptions);
const oidcService = new OIDCService();
async function onRefresh(options: SearchPaginationSortRequest) {
async function onRefresh(options: ListRequestOptions) {
clients = await oidcService.listOwnAccessibleClients(options);
}
@@ -83,6 +83,10 @@
{#each clients.data as client}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if clients.data.length == 2}
<div></div>
{/if}
</div>
{#if clients.pagination.totalPages > 1}

View File

@@ -1,11 +1,11 @@
import OIDCService from '$lib/services/oidc-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const oidcService = new OIDCService();
const appRequestOptions: SearchPaginationSortRequest = {
const appRequestOptions: ListRequestOptions = {
pagination: {
page: 1,
limit: 20

View File

@@ -38,7 +38,7 @@
<div class="flex gap-3">
<div class="aspect-square h-[56px]">
<ImageBox
class="size-8"
class="size-14"
src={client.hasLogo
? cachedOidcClientLogo.getUrl(client.id)
: cachedApplicationLogo.getUrl(isLightMode)}

View File

@@ -5,9 +5,6 @@
import userStore from '$lib/stores/user-store';
import { LogsIcon } from '@lucide/svelte';
import AuditLogSwitcher from './audit-log-switcher.svelte';
let { data } = $props();
let auditLogsRequestOptions = $state(data.auditLogsRequestOptions);
</script>
<svelte:head>
@@ -19,7 +16,7 @@
{/if}
<div>
<Card.Root>
<Card.Root class="gap-0">
<Card.Header>
<Card.Title>
<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.Header>
<Card.Content>
<AuditLogList auditLogs={data.auditLogs} requestOptions={auditLogsRequestOptions} />
<AuditLogList />
</Card.Content>
</Card.Root>
</div>

View File

@@ -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 };
};

View File

@@ -6,15 +6,11 @@
import { m } from '$lib/paraglide/messages';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLogFilter } from '$lib/types/audit-log.type';
import { eventTypes as eventTranslations } from '$lib/utils/audit-log-translator';
import AuditLogSwitcher from '../audit-log-switcher.svelte';
import {eventTypes as eventTranslations} from "$lib/utils/audit-log-translator";
let { data } = $props();
const auditLogService = new AuditLogService();
let auditLogs = $state(data.auditLogs);
let requestOptions = $state(data.requestOptions);
let auditLogListRef: AuditLogList;
let filters: AuditLogFilter = $state({
userId: '',
@@ -29,10 +25,6 @@
});
const eventTypes = $state(eventTranslations);
$effect(() => {
auditLogService.listAllLogs(requestOptions, filters).then((response) => (auditLogs = response));
});
</script>
<svelte:head>
@@ -124,7 +116,6 @@
{/await}
</div>
</div>
<AuditLogList isAdmin={true} {auditLogs} {requestOptions} />
<AuditLogList bind:this={auditLogListRef} isAdmin {filters} />
</Card.Content>
</Card.Root>

View File

@@ -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
};
};

View File

@@ -18,4 +18,4 @@ services:
args:
- BUILD_TAGS=e2etest
context: ../..
dockerfile: Dockerfile
dockerfile: docker/Dockerfile

View File

@@ -56,11 +56,13 @@ test.describe('API Key Management', () => {
await page
.getByRole('row', { name: apiKey.name })
.getByRole('button', { name: 'Revoke' })
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByText('Revoke', { exact: true }).click();
await page.getByRole('menuitem', { name: 'Revoke' }).click();
await page.getByRole('button', { name: 'Revoke' }).click();
// Verify success message
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');

View File

@@ -97,8 +97,14 @@ test('Delete OIDC client', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto('/settings/admin/oidc-clients');
await page.getByRole('row', { name: oidcClient.name }).getByLabel('Delete').click();
await page.getByText('Delete', { exact: true }).click();
await page
.getByRole('row', { name: oidcClient.name })
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'OIDC client deleted successfully'

View File

@@ -45,8 +45,8 @@ test('Update user group users', async ({ page }) => {
const group = userGroups.designers;
await page.goto(`/settings/admin/user-groups/${group.id}`);
await page.getByRole('row', { name: users.tim.email }).getByRole('checkbox').click();
await page.getByRole('row', { name: users.craig.email }).getByRole('checkbox').click();
await page.getByRole('row', { name: users.tim.username }).getByRole('checkbox').click();
await page.getByRole('row', { name: users.craig.username }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
@@ -55,10 +55,10 @@ test('Update user group users', async ({ page }) => {
await page.reload();
await expect(
page.getByRole('row', { name: users.tim.email }).getByRole('checkbox')
page.getByRole('row', { name: users.tim.username }).getByRole('checkbox')
).toHaveAttribute('data-state', 'unchecked');
await expect(
page.getByRole('row', { name: users.craig.email }).getByRole('checkbox')
page.getByRole('row', { name: users.craig.username }).getByRole('checkbox')
).toHaveAttribute('data-state', 'checked');
});
@@ -108,12 +108,12 @@ test('Update user group custom claims', async ({ page }) => {
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
await expect(page.locator('[data-type="success"]')).toHaveText(
'Custom claims updated successfully'
);
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForLoadState('networkidle');
// Check if custom claim is removed
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');