mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 22:52:58 +03:00
feat: global audit log (#320)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -5,7 +5,7 @@
|
||||
"name": "Backend",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/backend/.env.example",
|
||||
"envFile": "${workspaceFolder}/backend/cmd/.env",
|
||||
"env": {
|
||||
"APP_ENV": "development"
|
||||
},
|
||||
@@ -16,7 +16,7 @@
|
||||
"name": "Frontend",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/frontend/.env.example",
|
||||
"envFile": "${workspaceFolder}/frontend/.env",
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
|
||||
@@ -20,7 +20,10 @@ func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.Audi
|
||||
auditLogService: auditLogService,
|
||||
}
|
||||
|
||||
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
|
||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
|
||||
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
|
||||
}
|
||||
|
||||
type AuditLogController struct {
|
||||
@@ -72,3 +75,86 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// listAllAuditLogsHandler godoc
|
||||
// @Summary List all audit logs
|
||||
// @Description Get a paginated list of all audit logs (admin only)
|
||||
// @Tags Audit Logs
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Param user_id query string false "Filter by user ID"
|
||||
// @Param event query string false "Filter by event type"
|
||||
// @Param client_name query string false "Filter by client name"
|
||||
// @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
|
||||
}
|
||||
|
||||
var filters dto.AuditLogFilterDto
|
||||
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(sortedPaginationRequest, filters)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var logsDtos []dto.AuditLogDto
|
||||
err = dto.MapStructList(logs, &logsDtos)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
for i, logsDto := range logsDtos {
|
||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||
logsDto.Username = logs[i].User.Username
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||
Data: logsDtos,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// listClientNamesHandler godoc
|
||||
// @Summary List client names
|
||||
// @Description Get a list of all client names for audit log filtering
|
||||
// @Tags Audit Logs
|
||||
// @Success 200 {array} string "List of client names"
|
||||
// @Router /api/audit-logs/filters/client-names [get]
|
||||
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
|
||||
names, err := alc.auditLogService.ListClientNames()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// listUserNamesWithIdsHandler godoc
|
||||
// @Summary List users with IDs
|
||||
// @Description Get a list of all usernames with their IDs for audit log filtering
|
||||
// @Tags Audit Logs
|
||||
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
|
||||
// @Router /api/audit-logs/filters/users [get]
|
||||
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
|
||||
users, err := alc.auditLogService.ListUsernamesWithIds()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
@@ -15,5 +15,12 @@ type AuditLogDto struct {
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
UserID string `json:"userID"`
|
||||
Username string `json:"username"`
|
||||
Data model.AuditLogData `json:"data"`
|
||||
}
|
||||
|
||||
type AuditLogFilterDto struct {
|
||||
UserID string `form:"filters[userId]"`
|
||||
Event string `form:"filters[event]"`
|
||||
ClientName string `form:"filters[clientName]"`
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ type AuditLog struct {
|
||||
Country string `sortable:"true"`
|
||||
City string `sortable:"true"`
|
||||
UserAgent string `sortable:"true"`
|
||||
UserID string
|
||||
Username string `gorm:"-"`
|
||||
Data AuditLogData
|
||||
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
|
||||
type AuditLogData map[string]string //nolint:recvcheck
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
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"
|
||||
@@ -97,3 +99,91 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
||||
ua := userAgentParser.Parse(userAgent)
|
||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||
}
|
||||
|
||||
func (s *AuditLogService) ListAllAuditLogs(sortedPaginationRequest utils.SortedPaginationRequest, filters dto.AuditLogFilterDto) ([]model.AuditLog, utils.PaginationResponse, error) {
|
||||
var logs []model.AuditLog
|
||||
|
||||
query := s.db.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 != "" {
|
||||
dialect := s.db.Name()
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
|
||||
case "postgres":
|
||||
query = query.Where("data->>'clientName' = ?", filters.ClientName)
|
||||
default:
|
||||
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
||||
}
|
||||
}
|
||||
|
||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
||||
if err != nil {
|
||||
return nil, pagination, err
|
||||
}
|
||||
|
||||
return logs, pagination, nil
|
||||
}
|
||||
|
||||
func (s *AuditLogService) ListUsernamesWithIds() (users map[string]string, err error) {
|
||||
query := s.db.Joins("User").Model(&model.AuditLog{}).
|
||||
Select("DISTINCT User.id, User.username").
|
||||
Where("User.username IS NOT NULL")
|
||||
|
||||
type Result struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
|
||||
var results []Result
|
||||
if err := query.Find(&results).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query user IDs: %w", err)
|
||||
}
|
||||
|
||||
users = make(map[string]string)
|
||||
for _, result := range results {
|
||||
users[result.ID] = result.Username
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *AuditLogService) ListClientNames() (clientNames []string, err error) {
|
||||
dialect := s.db.Name()
|
||||
var query *gorm.DB
|
||||
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
query = s.db.Model(&model.AuditLog{}).
|
||||
Select("DISTINCT json_extract(data, '$.clientName') as clientName").
|
||||
Where("json_extract(data, '$.clientName') IS NOT NULL")
|
||||
case "postgres":
|
||||
query = s.db.Model(&model.AuditLog{}).
|
||||
Select("DISTINCT data->>'clientName' as clientName").
|
||||
Where("data->>'clientName' IS NOT NULL")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database dialect: %s", dialect)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ClientName string `gorm:"column:clientName"`
|
||||
}
|
||||
|
||||
var results []Result
|
||||
if err := query.Find(&results).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query client IDs: %w", err)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
clientNames = append(clientNames, result.ClientName)
|
||||
|
||||
}
|
||||
|
||||
return clientNames, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -36,7 +38,12 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
|
||||
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
|
||||
|
||||
if sortFieldFound && isSortable && isValidSortOrder {
|
||||
query = query.Order(CamelCaseToSnakeCase(sort.Column) + " " + sort.Direction)
|
||||
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)
|
||||
|
||||
@@ -312,5 +312,15 @@
|
||||
"reset": "Reset",
|
||||
"reset_to_default": "Reset to default",
|
||||
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
|
||||
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated."
|
||||
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
|
||||
"personal": "Personal",
|
||||
"global": "Global",
|
||||
"all_users": "All Users",
|
||||
"all_events": "All Events",
|
||||
"all_clients": "All Clients",
|
||||
"global_audit_log": "Global Audit Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||
"token_sign_in": "Token Sign In",
|
||||
"client_authorization": "Client Authorization",
|
||||
"new_client_authorization": "New Client Authorization"
|
||||
}
|
||||
|
||||
75
frontend/package-lock.json
generated
75
frontend/package-lock.json
generated
@@ -11,12 +11,11 @@
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.8.2",
|
||||
"bits-ui": "^0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto": "^1.0.1",
|
||||
"formsnap": "^1.0.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-svelte": "^0.483.0",
|
||||
"lucide-svelte": "^0.487.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"sveltekit-superforms": "^2.23.1",
|
||||
@@ -36,6 +35,8 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.10.10",
|
||||
"bits-ui": "^0.22.0",
|
||||
"cmdk-sv": "^0.0.19",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
@@ -633,6 +634,7 @@
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
|
||||
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
@@ -641,6 +643,7 @@
|
||||
"version": "1.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
|
||||
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
@@ -649,7 +652,8 @@
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@gcornut/valibot-json-schema": {
|
||||
"version": "0.31.0",
|
||||
@@ -868,6 +872,7 @@
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
|
||||
"integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
@@ -1470,6 +1475,7 @@
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
@@ -2110,6 +2116,7 @@
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.22.0.tgz",
|
||||
"integrity": "sha512-r7Fw1HNgA4YxZBRcozl7oP0bheQ8EHh+kfMBZJgyFISix8t4p/nqDcHLmBgIiJ3T5XjYnJRorYDjIWaCfhb5fw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.5.1",
|
||||
"@melt-ui/svelte": "0.76.2",
|
||||
@@ -2126,6 +2133,7 @@
|
||||
"version": "0.76.2",
|
||||
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz",
|
||||
"integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@floating-ui/dom": "^1.4.5",
|
||||
@@ -2237,6 +2245,53 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk-sv": {
|
||||
"version": "0.0.19",
|
||||
"resolved": "https://registry.npmjs.org/cmdk-sv/-/cmdk-sv-0.0.19.tgz",
|
||||
"integrity": "sha512-Vm6+/up5nQbwGmNw0uYFbXTrbpF460/FhMohTskzzszMtIR/u/78haucSmJWjjp9bW0wxpHXZWa6fk+Lj1tKnA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"bits-ui": "^0.21.12",
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk-sv/node_modules/bits-ui": {
|
||||
"version": "0.21.16",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.16.tgz",
|
||||
"integrity": "sha512-XFZ7/bK7j/K+5iktxX/ZpmoFHjYjpPzP5EOO/4bWiaFg5TG1iMcfjDhlBTQnJxD6BoVoHuqeZPHZvaTgF4Iv3Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.5.1",
|
||||
"@melt-ui/svelte": "0.76.2",
|
||||
"nanoid": "^5.0.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/huntabyte"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.118"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk-sv/node_modules/bits-ui/node_modules/@melt-ui/svelte": {
|
||||
"version": "0.76.2",
|
||||
"resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz",
|
||||
"integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.3.1",
|
||||
"@floating-ui/dom": "^1.4.5",
|
||||
"@internationalized/date": "^3.5.0",
|
||||
"dequal": "^2.0.3",
|
||||
"focus-trap": "^7.5.2",
|
||||
"nanoid": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": ">=3 <5"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2425,6 +2480,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -2953,6 +3009,7 @@
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz",
|
||||
"integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tabbable": "^6.2.0"
|
||||
}
|
||||
@@ -3605,9 +3662,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lucide-svelte": {
|
||||
"version": "0.483.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.483.0.tgz",
|
||||
"integrity": "sha512-MyMgEVLlFfPbyodGpkB+KCpyPkpjI7EKiFw1crA92B1ZXRK5hq5vTsGWAm9Nt3GAKHunoNc5MVsq3EOCz0DZSQ==",
|
||||
"version": "0.487.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.487.0.tgz",
|
||||
"integrity": "sha512-ags7npK5Nv6AI9LBpAbijpmHxcO9hOeP0/7yJ72++AOBWmwB0X7LqNQFJk1PBe4wM0UsgklWQdyRZyx8s9xyXg==",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
}
|
||||
@@ -4697,7 +4754,8 @@
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.6.0",
|
||||
@@ -4806,7 +4864,8 @@
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.8.2",
|
||||
"bits-ui": "^0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto": "^1.0.1",
|
||||
"formsnap": "^1.0.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-svelte": "^0.483.0",
|
||||
"lucide-svelte": "^0.487.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"sveltekit-superforms": "^2.23.1",
|
||||
@@ -41,6 +40,8 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.10.10",
|
||||
"bits-ui": "^0.22.0",
|
||||
"cmdk-sv": "^0.0.19",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
|
||||
let {
|
||||
auditLogs,
|
||||
isAdmin = false,
|
||||
requestOptions
|
||||
}: { auditLogs: Paginated<AuditLog>; requestOptions: SearchPaginationSortRequest } = $props();
|
||||
}: {
|
||||
auditLogs: Paginated<AuditLog>;
|
||||
isAdmin?: boolean;
|
||||
requestOptions: SearchPaginationSortRequest;
|
||||
} = $props();
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
@@ -26,9 +31,13 @@
|
||||
<AdvancedTable
|
||||
items={auditLogs}
|
||||
{requestOptions}
|
||||
onRefresh={async (options) => (auditLogs = await auditLogService.list(options))}
|
||||
onRefresh={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' },
|
||||
@@ -39,6 +48,15 @@
|
||||
>
|
||||
{#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 variant="outline">{toFriendlyEventString(item.event)}</Badge>
|
||||
</Table.Cell>
|
||||
@@ -27,7 +27,6 @@
|
||||
if (child.nodeType === 1) {
|
||||
const itemDelay = delay + index * stagger;
|
||||
(child as HTMLElement).style.setProperty('animation-delay', `${itemDelay}ms`);
|
||||
console.log(itemDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -43,7 +42,7 @@
|
||||
}
|
||||
|
||||
/* Apply these styles to all children */
|
||||
.fade-wrapper > * {
|
||||
.fade-wrapper > *:not(.no-fade) {
|
||||
animation-fill-mode: both;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
|
||||
90
frontend/src/lib/components/form/searchable-select.svelte
Normal file
90
frontend/src/lib/components/form/searchable-select.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideCheck, LucideChevronDown } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
items,
|
||||
value = $bindable(),
|
||||
onSelect,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLButtonElement> & {
|
||||
items: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
value: string;
|
||||
onSelect?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let filteredItems = $state(items);
|
||||
|
||||
// We want to refocus the trigger button when the user selects
|
||||
// an item from the list so users can continue navigating the
|
||||
// rest of the form with the keyboard.
|
||||
function closeAndFocusTrigger(triggerId: string) {
|
||||
open = false;
|
||||
tick().then(() => {
|
||||
document.getElementById(triggerId)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function filterItems(searchString: string) {
|
||||
if (!searchString) {
|
||||
filteredItems = items;
|
||||
} else {
|
||||
filteredItems = items.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchString.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset items when opening again
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
filteredItems = items;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open let:ids>
|
||||
<Popover.Trigger asChild let:builder>
|
||||
<Button
|
||||
{...restProps}
|
||||
builders={[builder]}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
class={cn('justify-between', restProps.class)}
|
||||
>
|
||||
{items.find((item) => item.value === value)?.label || 'Select an option'}
|
||||
<LucideChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="p-0" sameWidth>
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each filteredItems as item}
|
||||
<Command.Item
|
||||
value={item.value}
|
||||
onSelect={() => {
|
||||
value = item.value;
|
||||
onSelect?.(item.value);
|
||||
closeAndFocusTrigger(ids.trigger);
|
||||
}}
|
||||
>
|
||||
<LucideCheck class={cn('mr-2 h-4 w-4', value !== item.value && 'text-transparent')} />
|
||||
{item.label}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
23
frontend/src/lib/components/ui/command/command-dialog.svelte
Normal file
23
frontend/src/lib/components/ui/command/command-dialog.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import type { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import Command from "./command.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
|
||||
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
|
||||
|
||||
export let open: $$Props["open"] = false;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...$$restProps}>
|
||||
<Dialog.Content class="overflow-hidden p-0 shadow-lg">
|
||||
<Command
|
||||
class="[&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
|
||||
{...$$restProps}
|
||||
bind:value
|
||||
>
|
||||
<slot />
|
||||
</Command>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
12
frontend/src/lib/components/ui/command/command-empty.svelte
Normal file
12
frontend/src/lib/components/ui/command/command-empty.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CommandPrimitive.EmptyProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CommandPrimitive.Empty>
|
||||
18
frontend/src/lib/components/ui/command/command-group.svelte
Normal file
18
frontend/src/lib/components/ui/command/command-group.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
type $$Props = CommandPrimitive.GroupProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Group
|
||||
class={cn(
|
||||
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CommandPrimitive.Group>
|
||||
24
frontend/src/lib/components/ui/command/command-input.svelte
Normal file
24
frontend/src/lib/components/ui/command/command-input.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
import { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
type $$Props = CommandPrimitive.InputProps;
|
||||
|
||||
let className: ClassValue | undefined | null = undefined;
|
||||
export { className as class };
|
||||
export let value: string = '';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
class={cn(
|
||||
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
bind:value
|
||||
/>
|
||||
</div>
|
||||
24
frontend/src/lib/components/ui/command/command-item.svelte
Normal file
24
frontend/src/lib/components/ui/command/command-item.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CommandPrimitive.ItemProps;
|
||||
|
||||
export let asChild = false;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Item
|
||||
{asChild}
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:action
|
||||
let:attrs
|
||||
>
|
||||
<slot {action} {attrs} />
|
||||
</CommandPrimitive.Item>
|
||||
15
frontend/src/lib/components/ui/command/command-list.svelte
Normal file
15
frontend/src/lib/components/ui/command/command-list.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CommandPrimitive.ListProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CommandPrimitive.List>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CommandPrimitive.SeparatorProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
22
frontend/src/lib/components/ui/command/command.svelte
Normal file
22
frontend/src/lib/components/ui/command/command.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = CommandPrimitive.CommandProps;
|
||||
|
||||
export let value: $$Props["value"] = undefined;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Root
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CommandPrimitive.Root>
|
||||
37
frontend/src/lib/components/ui/command/index.ts
Normal file
37
frontend/src/lib/components/ui/command/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
|
||||
import Root from "./command.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
|
||||
const Loading = CommandPrimitive.Loading;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading,
|
||||
};
|
||||
18
frontend/src/lib/components/ui/tabs/index.ts
Normal file
18
frontend/src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import Content from "./tabs-content.svelte";
|
||||
import List from "./tabs-list.svelte";
|
||||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
const Root = TabsPrimitive.Root;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
21
frontend/src/lib/components/ui/tabs/tabs-content.svelte
Normal file
21
frontend/src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = TabsPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let value: $$Props["value"];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Content
|
||||
class={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{value}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</TabsPrimitive.Content>
|
||||
19
frontend/src/lib/components/ui/tabs/tabs-list.svelte
Normal file
19
frontend/src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = TabsPrimitive.ListProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.List
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</TabsPrimitive.List>
|
||||
23
frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
23
frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = TabsPrimitive.TriggerProps;
|
||||
type $$Events = TabsPrimitive.TriggerEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let value: $$Props["value"];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Trigger
|
||||
class={cn(
|
||||
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{value}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
</TabsPrimitive.Trigger>
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuditLog } from '$lib/types/audit-log.type';
|
||||
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
@@ -9,6 +9,26 @@ class AuditLogService extends APIService {
|
||||
});
|
||||
return res.data as Paginated<AuditLog>;
|
||||
}
|
||||
|
||||
async listAllLogs(options?: SearchPaginationSortRequest, filters?: AuditLogFilter) {
|
||||
const res = await this.api.get('/audit-logs/all', {
|
||||
params: {
|
||||
...options,
|
||||
filters
|
||||
}
|
||||
});
|
||||
return res.data as Paginated<AuditLog>;
|
||||
}
|
||||
|
||||
async listClientNames() {
|
||||
const res = await this.api.get<string[]>('/audit-logs/filters/client-names');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const res = await this.api.get<Record<string, string>>('/audit-logs/filters/users');
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuditLogService;
|
||||
|
||||
@@ -5,6 +5,14 @@ export type AuditLog = {
|
||||
country?: string;
|
||||
city?: string;
|
||||
device: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
createdAt: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type AuditLogFilter = {
|
||||
userId: string,
|
||||
event: string,
|
||||
clientName: string,
|
||||
}
|
||||
@@ -8,10 +8,13 @@ export type SortRequest = {
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type FilterMap = Record<string, string>;
|
||||
|
||||
export type SearchPaginationSortRequest = {
|
||||
search?: string;
|
||||
pagination?: PaginationRequest;
|
||||
sort?: SortRequest;
|
||||
filters?: FilterMap;
|
||||
};
|
||||
|
||||
export type PaginationResponse = {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import AuditLogList from '$lib/components/audit-log-list.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { LogsIcon } from 'lucide-svelte';
|
||||
import AuditLogList from './audit-log-list.svelte';
|
||||
import AuditLogSwitcher from './audit-log-switcher.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let auditLogsRequestOptions = $state(data.auditLogsRequestOptions);
|
||||
@@ -12,6 +13,8 @@
|
||||
<title>{m.audit_log()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<AuditLogSwitcher currentPage="personal" />
|
||||
|
||||
<div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
let { currentPage }: { currentPage: 'personal' | 'global' } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end no-fade">
|
||||
<Tabs.Root value={currentPage} >
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger onclick={() => goto('/settings/audit-log')} value="personal"
|
||||
>{m.personal()}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger onclick={() => goto('/settings/audit-log/global')} value="global"
|
||||
>{m.global()}</Tabs.Trigger
|
||||
>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
|
||||
import AuditLogService from '$lib/services/audit-log-service';
|
||||
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { PageServerLoad } from '../../global-audit-log/$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
const requestOptions: SearchPaginationSortRequest = {
|
||||
sort: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
};
|
||||
|
||||
const auditLogs = await auditLogService.listAllLogs(requestOptions);
|
||||
|
||||
return {
|
||||
auditLogs,
|
||||
requestOptions
|
||||
};
|
||||
};
|
||||
116
frontend/src/routes/settings/audit-log/global/+page.svelte
Normal file
116
frontend/src/routes/settings/audit-log/global/+page.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import AuditLogList from '$lib/components/audit-log-list.svelte';
|
||||
import SearchableSelect from '$lib/components/form/searchable-select.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import AuditLogService from '$lib/services/audit-log-service';
|
||||
import type { AuditLogFilter } from '$lib/types/audit-log.type';
|
||||
import AuditLogSwitcher from '../audit-log-switcher.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
let auditLogs = $state(data.auditLogs);
|
||||
let requestOptions = $state(data.requestOptions);
|
||||
|
||||
let filters: AuditLogFilter = $state({
|
||||
userId: '',
|
||||
event: '',
|
||||
clientName: ''
|
||||
});
|
||||
|
||||
const eventTypes = $state({
|
||||
SIGN_IN: m.sign_in(),
|
||||
TOKEN_SIGN_IN: m.token_sign_in(),
|
||||
CLIENT_AUTHORIZATION: m.client_authorization(),
|
||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization()
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
auditLogService.listAllLogs(requestOptions, filters).then((response) => (auditLogs = response));
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.global_audit_log()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<AuditLogSwitcher currentPage="global" />
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.global_audit_log()}</Card.Title>
|
||||
<Card.Description class="mt-1"
|
||||
>{m.see_all_account_activities_from_the_last_3_months()}</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
{#await auditLogService.listUsers()}
|
||||
<Select.Root>
|
||||
<Select.Trigger class="w-full" disabled>
|
||||
<Select.Value placeholder={m.all_users()} />
|
||||
</Select.Trigger>
|
||||
</Select.Root>
|
||||
{:then users}
|
||||
<SearchableSelect
|
||||
class="w-full"
|
||||
items={[
|
||||
{ value: '', label: m.all_users() },
|
||||
...Object.entries(users).map(([id, username]) => ({
|
||||
value: id,
|
||||
label: username
|
||||
}))
|
||||
]}
|
||||
bind:value={filters.userId}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
<div>
|
||||
<Select.Root
|
||||
selected={{
|
||||
value: filters.event,
|
||||
label: eventTypes[filters.event as keyof typeof eventTypes]
|
||||
}}
|
||||
onSelectedChange={(v) => (filters.event = v!.value)}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
<Select.Value placeholder={m.all_events()} />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">{m.all_events()}</Select.Item>
|
||||
{#each Object.entries(eventTypes) as [value, label]}
|
||||
<Select.Item {value}>{label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div>
|
||||
{#await auditLogService.listClientNames()}
|
||||
<Select.Root>
|
||||
<Select.Trigger class="w-full" disabled>
|
||||
<Select.Value placeholder={m.all_clients()} />
|
||||
</Select.Trigger>
|
||||
</Select.Root>
|
||||
{:then clientNames}
|
||||
<SearchableSelect
|
||||
class="w-full"
|
||||
items={[
|
||||
{ value: '', label: m.all_clients() },
|
||||
...clientNames.map((name) => ({
|
||||
value: name,
|
||||
label: name
|
||||
}))
|
||||
]}
|
||||
bind:value={filters.clientName}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuditLogList isAdmin={true} {auditLogs} {requestOptions} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
Reference in New Issue
Block a user