diff --git a/.vscode/launch.json b/.vscode/launch.json index b7e3f23d..2908fccf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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": [ diff --git a/backend/internal/controller/audit_log_controller.go b/backend/internal/controller/audit_log_controller.go index 6f3d2da0..43fc8aeb 100644 --- a/backend/internal/controller/audit_log_controller.go +++ b/backend/internal/controller/audit_log_controller.go @@ -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) +} diff --git a/backend/internal/dto/audit_log_dto.go b/backend/internal/dto/audit_log_dto.go index 0f483452..ed3f2072 100644 --- a/backend/internal/dto/audit_log_dto.go +++ b/backend/internal/dto/audit_log_dto.go @@ -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]"` +} diff --git a/backend/internal/model/audit_log.go b/backend/internal/model/audit_log.go index fb89c6a7..63ea3ebc 100644 --- a/backend/internal/model/audit_log.go +++ b/backend/internal/model/audit_log.go @@ -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 diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index b5918e8b..678dfa23 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -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 +} diff --git a/backend/internal/utils/paging_util.go b/backend/internal/utils/paging_util.go index 7be8d72f..7ef94318 100644 --- a/backend/internal/utils/paging_util.go +++ b/backend/internal/utils/paging_util.go @@ -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) diff --git a/frontend/messages/en-US.json b/frontend/messages/en-US.json index 336fd6df..5152582d 100644 --- a/frontend/messages/en-US.json +++ b/frontend/messages/en-US.json @@ -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" } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f076a28..dea7fef6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index eaf74ca0..50c1bdbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/routes/settings/audit-log/audit-log-list.svelte b/frontend/src/lib/components/audit-log-list.svelte similarity index 76% rename from frontend/src/routes/settings/audit-log/audit-log-list.svelte rename to frontend/src/lib/components/audit-log-list.svelte index eb3cda96..12c54e25 100644 --- a/frontend/src/routes/settings/audit-log/audit-log-list.svelte +++ b/frontend/src/lib/components/audit-log-list.svelte @@ -9,8 +9,13 @@ let { auditLogs, + isAdmin = false, requestOptions - }: { auditLogs: Paginated; requestOptions: SearchPaginationSortRequest } = $props(); + }: { + auditLogs: Paginated; + isAdmin?: boolean; + requestOptions: SearchPaginationSortRequest; + } = $props(); const auditLogService = new AuditLogService(); @@ -26,9 +31,13 @@ (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 })} {new Date(item.createdAt).toLocaleString()} + {#if isAdmin} + + {#if item.username} + {item.username} + {:else} + Unknown User + {/if} + + {/if} {toFriendlyEventString(item.event)} diff --git a/frontend/src/lib/components/fade-wrapper.svelte b/frontend/src/lib/components/fade-wrapper.svelte index 84df4b38..5740fa0e 100644 --- a/frontend/src/lib/components/fade-wrapper.svelte +++ b/frontend/src/lib/components/fade-wrapper.svelte @@ -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); diff --git a/frontend/src/lib/components/form/searchable-select.svelte b/frontend/src/lib/components/form/searchable-select.svelte new file mode 100644 index 00000000..15aefbc7 --- /dev/null +++ b/frontend/src/lib/components/form/searchable-select.svelte @@ -0,0 +1,90 @@ + + + + + + + + + filterItems(e.target.value)} /> + No results found. + + {#each filteredItems as item} + { + value = item.value; + onSelect?.(item.value); + closeAndFocusTrigger(ids.trigger); + }} + > + + {item.label} + + {/each} + + + + diff --git a/frontend/src/lib/components/ui/command/command-dialog.svelte b/frontend/src/lib/components/ui/command/command-dialog.svelte new file mode 100644 index 00000000..68398a84 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-dialog.svelte @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/ui/command/command-empty.svelte b/frontend/src/lib/components/ui/command/command-empty.svelte new file mode 100644 index 00000000..4d4d1777 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-empty.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/frontend/src/lib/components/ui/command/command-group.svelte b/frontend/src/lib/components/ui/command/command-group.svelte new file mode 100644 index 00000000..a23826e0 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-group.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/frontend/src/lib/components/ui/command/command-input.svelte b/frontend/src/lib/components/ui/command/command-input.svelte new file mode 100644 index 00000000..8da0989e --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-input.svelte @@ -0,0 +1,24 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/ui/command/command-item.svelte b/frontend/src/lib/components/ui/command/command-item.svelte new file mode 100644 index 00000000..4ee16497 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-item.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/frontend/src/lib/components/ui/command/command-list.svelte b/frontend/src/lib/components/ui/command/command-list.svelte new file mode 100644 index 00000000..1bcfaf75 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-list.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/lib/components/ui/command/command-separator.svelte b/frontend/src/lib/components/ui/command/command-separator.svelte new file mode 100644 index 00000000..c8dea30b --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-separator.svelte @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/lib/components/ui/command/command-shortcut.svelte b/frontend/src/lib/components/ui/command/command-shortcut.svelte new file mode 100644 index 00000000..6d62a0c1 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-shortcut.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/frontend/src/lib/components/ui/command/command.svelte b/frontend/src/lib/components/ui/command/command.svelte new file mode 100644 index 00000000..d1d159f1 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/lib/components/ui/command/index.ts b/frontend/src/lib/components/ui/command/index.ts new file mode 100644 index 00000000..d8a2e7c0 --- /dev/null +++ b/frontend/src/lib/components/ui/command/index.ts @@ -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, +}; diff --git a/frontend/src/lib/components/ui/tabs/index.ts b/frontend/src/lib/components/ui/tabs/index.ts new file mode 100644 index 00000000..f1ab372c --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/index.ts @@ -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, +}; diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 00000000..d9ce2711 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 00000000..0cf54007 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 00000000..75948c67 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/frontend/src/lib/services/audit-log-service.ts b/frontend/src/lib/services/audit-log-service.ts index 393e7628..68b537fa 100644 --- a/frontend/src/lib/services/audit-log-service.ts +++ b/frontend/src/lib/services/audit-log-service.ts @@ -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; } + + async listAllLogs(options?: SearchPaginationSortRequest, filters?: AuditLogFilter) { + const res = await this.api.get('/audit-logs/all', { + params: { + ...options, + filters + } + }); + return res.data as Paginated; + } + + async listClientNames() { + const res = await this.api.get('/audit-logs/filters/client-names'); + return res.data; + } + + async listUsers() { + const res = await this.api.get>('/audit-logs/filters/users'); + return res.data; + } } export default AuditLogService; diff --git a/frontend/src/lib/types/audit-log.type.ts b/frontend/src/lib/types/audit-log.type.ts index e9d024ed..2ecfa2dd 100644 --- a/frontend/src/lib/types/audit-log.type.ts +++ b/frontend/src/lib/types/audit-log.type.ts @@ -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, +} \ No newline at end of file diff --git a/frontend/src/lib/types/pagination.type.ts b/frontend/src/lib/types/pagination.type.ts index 2c84279b..6f44c9d6 100644 --- a/frontend/src/lib/types/pagination.type.ts +++ b/frontend/src/lib/types/pagination.type.ts @@ -8,10 +8,13 @@ export type SortRequest = { direction: 'asc' | 'desc'; }; +export type FilterMap = Record; + export type SearchPaginationSortRequest = { search?: string; pagination?: PaginationRequest; sort?: SortRequest; + filters?: FilterMap; }; export type PaginationResponse = { diff --git a/frontend/src/routes/settings/audit-log/+page.svelte b/frontend/src/routes/settings/audit-log/+page.svelte index eba8c79a..4105a2a1 100644 --- a/frontend/src/routes/settings/audit-log/+page.svelte +++ b/frontend/src/routes/settings/audit-log/+page.svelte @@ -1,8 +1,9 @@ + +
+ + + goto('/settings/audit-log')} value="personal" + >{m.personal()} + goto('/settings/audit-log/global')} value="global" + >{m.global()} + + +
diff --git a/frontend/src/routes/settings/audit-log/global/+page.server.ts b/frontend/src/routes/settings/audit-log/global/+page.server.ts new file mode 100644 index 00000000..80fdf5b3 --- /dev/null +++ b/frontend/src/routes/settings/audit-log/global/+page.server.ts @@ -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 + }; +}; diff --git a/frontend/src/routes/settings/audit-log/global/+page.svelte b/frontend/src/routes/settings/audit-log/global/+page.svelte new file mode 100644 index 00000000..4d81d6f3 --- /dev/null +++ b/frontend/src/routes/settings/audit-log/global/+page.svelte @@ -0,0 +1,116 @@ + + + + {m.global_audit_log()} + + + + + + + {m.global_audit_log()} + {m.see_all_account_activities_from_the_last_3_months()} + + +
+
+ {#await auditLogService.listUsers()} + + + + + + {:then users} + ({ + value: id, + label: username + })) + ]} + bind:value={filters.userId} + /> + {/await} +
+
+ (filters.event = v!.value)} + > + + + + + {m.all_events()} + {#each Object.entries(eventTypes) as [value, label]} + {label} + {/each} + + +
+
+ {#await auditLogService.listClientNames()} + + + + + + {:then clientNames} + ({ + value: name, + label: name + })) + ]} + bind:value={filters.clientName} + /> + {/await} +
+
+ + +
+