diff --git a/backend/internal/auditlogs/date_range_test.go b/backend/internal/auditlogs/date_range_test.go new file mode 100644 index 00000000..9af00901 --- /dev/null +++ b/backend/internal/auditlogs/date_range_test.go @@ -0,0 +1,62 @@ +package auditlogs + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func TestListAuditLogsByDateRange(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{}) + require.NoError(t, db.Create(&model.User{Base: model.Base{ID: "alice"}, Username: "alice"}).Error) + + day := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC) + for i, at := range []time.Time{day.Add(-time.Second), day, day.Add(12 * time.Hour), day.Add(24 * time.Hour)} { + id := string(rune('a' + i)) + require.NoError(t, db.Create(&AuditLog{Base: model.Base{ID: id}, UserID: "alice", Event: EventSignIn}).Error) + // Creating always stamps the current time, so the test time is set afterwards + require.NoError(t, db.Model(&AuditLog{}).Where("id = ?", id).Update("created_at", datatype.DateTime(at)).Error) + } + + ids := func(logs []AuditLog) []string { + out := make([]string, len(logs)) + for i, l := range logs { + out[i] = l.ID + } + return out + } + oneDay := DateRange{From: day, To: day.Add(24 * time.Hour)} + + all, _, err := s.ListAllAuditLogs(t.Context(), oneDay, utils.ListRequestOptions{}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"b", "c"}, ids(all)) + + own, _, err := s.ListAuditLogsForUser(t.Context(), "alice", DateRange{From: day.Add(time.Hour)}, utils.ListRequestOptions{}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"c", "d"}, ids(own)) +} + +func TestParseDateRange(t *testing.T) { + parse := func(query string) (DateRange, error) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(t.Context(), "GET", "/api/audit-logs?"+query, nil) + return parseDateRange(c) + } + + r, err := parse("from=2026-10-10T00:00:00%2B02:00") + require.NoError(t, err) + require.True(t, r.From.Equal(time.Date(2026, 10, 9, 22, 0, 0, 0, time.UTC))) + require.True(t, r.To.IsZero()) + + _, err = parse("to=yesterday") + require.Error(t, err) +} diff --git a/backend/internal/auditlogs/handler.go b/backend/internal/auditlogs/handler.go index 61d982f2..0d9955ab 100644 --- a/backend/internal/auditlogs/handler.go +++ b/backend/internal/auditlogs/handler.go @@ -2,9 +2,11 @@ package auditlogs import ( "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/authz" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -18,6 +20,23 @@ func newHandler(service *service) *handler { return &handler{service: service} } +// parseDateRange reads the optional from and to query parameters, RFC 3339 times that bound the list by creation time +func parseDateRange(c *gin.Context) (DateRange, error) { + var r DateRange + for name, bound := range map[string]*time.Time{"from": &r.From, "to": &r.To} { + value := c.Query(name) + if value == "" { + continue + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return r, apperror.InvalidField(name, "invalid", "must be an RFC 3339 time") + } + *bound = t + } + return r, nil +} + // listAuditLogsForUserHandler godoc // @Summary List audit logs // @Description Get a paginated list of audit logs for the current user @@ -26,16 +45,22 @@ func newHandler(service *service) *handler { // @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 from query string false "Only entries created at or after this RFC 3339 time" +// @Param to query string false "Only entries created before this RFC 3339 time" // @Success 200 {object} dto.Paginated[auditLogDto] // @Failure default {object} dto.ErrorDto "Error" // @Router /api/audit-logs [get] func (h *handler) listAuditLogsForUserHandler(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) + dateRange, err := parseDateRange(c) + if err != nil { + return err + } userID := authz.PrincipalFrom(c).UserID // Fetch audit logs for the user - logs, pagination, err := h.service.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions) + logs, pagination, err := h.service.ListAuditLogsForUser(c.Request.Context(), userID, dateRange, listRequestOptions) if err != nil { return err } @@ -69,13 +94,19 @@ func (h *handler) listAuditLogsForUserHandler(c *gin.Context) error { // @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 from query string false "Only entries created at or after this RFC 3339 time" +// @Param to query string false "Only entries created before this RFC 3339 time" // @Success 200 {object} dto.Paginated[auditLogDto] // @Failure default {object} dto.ErrorDto "Error" // @Router /api/audit-logs/all [get] func (h *handler) listAllAuditLogsHandler(c *gin.Context) error { listRequestOptions := utils.ParseListRequestOptions(c) + dateRange, err := parseDateRange(c) + if err != nil { + return err + } - logs, pagination, err := h.service.ListAllAuditLogs(c.Request.Context(), listRequestOptions) + logs, pagination, err := h.service.ListAllAuditLogs(c.Request.Context(), dateRange, listRequestOptions) if err != nil { return err } diff --git a/backend/internal/auditlogs/service.go b/backend/internal/auditlogs/service.go index 89deab8b..9b7b8b03 100644 --- a/backend/internal/auditlogs/service.go +++ b/backend/internal/auditlogs/service.go @@ -4,12 +4,14 @@ import ( "context" "fmt" "log/slog" + "time" userAgentParser "github.com/mileusna/useragent" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/iplocation" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" ) @@ -66,13 +68,29 @@ func (s *service) Create(ctx context.Context, event Event, ipAddress, userAgent, return auditLog, true } +// DateRange bounds a list by creation time, from inclusive and to exclusive; a zero bound is open +type DateRange struct { + From time.Time + To time.Time +} + +func (r DateRange) apply(query *gorm.DB) *gorm.DB { + if !r.From.IsZero() { + query = query.Where("created_at >= ?", datatype.DateTime(r.From)) + } + if !r.To.IsZero() { + query = query.Where("created_at < ?", datatype.DateTime(r.To)) + } + return query +} + // ListAuditLogsForUser retrieves all audit logs for a given user ID -func (s *service) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) { +func (s *service) ListAuditLogsForUser(ctx context.Context, userID string, dateRange DateRange, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) { var logs []AuditLog - query := s.db. + query := dateRange.apply(s.db. WithContext(ctx). Model(&AuditLog{}). - Where("user_id = ?", userID) + Where("user_id = ?", userID)) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs) return logs, pagination, err @@ -83,13 +101,13 @@ func (s *service) DeviceStringFromUserAgent(userAgent string) string { return ua.Name + " on " + ua.OS + " " + ua.OSVersion } -func (s *service) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) { +func (s *service) ListAllAuditLogs(ctx context.Context, dateRange DateRange, listRequestOptions utils.ListRequestOptions) ([]AuditLog, utils.PaginationResponse, error) { var logs []AuditLog - query := s.db. + query := dateRange.apply(s.db. WithContext(ctx). Preload("User"). - Model(&AuditLog{}) + Model(&AuditLog{})) if clientName, ok := listRequestOptions.Filters["clientName"]; ok { dialect := s.db.Name() diff --git a/backend/internal/auditlogs/signin.go b/backend/internal/auditlogs/signin.go index 347040d4..30344c36 100644 --- a/backend/internal/auditlogs/signin.go +++ b/backend/internal/auditlogs/signin.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" + userAgentParser "github.com/mileusna/useragent" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -13,7 +14,11 @@ import ( // CreateSignIn records a successful login and applies the global notification policy // The caller must send the notification only after the login commits func (s *service) CreateSignIn(ctx context.Context, event Event, ipAddress, userAgent, userID, browserToken string, tx *gorm.DB, notificationMode appconfig.AppConfigValue) SignInResult { - entry, created := s.Create(ctx, event, ipAddress, userAgent, userID, Data{}, tx) + data := Data{} + if s.isNewBrowser(ctx, tx, userID, userAgent) { + data["newBrowser"] = "true" + } + entry, created := s.Create(ctx, event, ipAddress, userAgent, userID, data, tx) result := SignInResult{AuditLog: entry, Created: created} if !created { return result @@ -43,7 +48,7 @@ func (s *service) CreateSignIn(ctx context.Context, event Event, ipAddress, user var count int64 query := tx.WithContext(ctx).Model(&AuditLog{}). Where("user_id = ? AND user_agent = ? AND id <> ?", userID, userAgent, entry.ID). - Where("event IN ?", []Event{EventSignIn, EventOneTimeAccessTokenSignIn, EventRemoteSignIn}) + Where("event IN ?", signInEvents) if ipAddress == "" { query = query.Where("ip_address IS NULL") } else { @@ -57,6 +62,39 @@ func (s *service) CreateSignIn(ctx context.Context, event Event, ipAddress, user return result } +var signInEvents = []Event{EventSignIn, EventOneTimeAccessTokenSignIn, EventRemoteSignIn} + +// isNewBrowser reports whether the user has signed in before, but never with this browser on this operating system +// It ignores versions, so a browser update is not a new browser, and it never flags an account's first sign-in +func (s *service) isNewBrowser(ctx context.Context, tx *gorm.DB, userID, userAgent string) bool { + var agents []string + err := tx.WithContext(ctx).Model(&AuditLog{}). + Where("user_id = ? AND event IN ?", userID, signInEvents). + Distinct(). + Pluck("user_agent", &agents). + Error + if err != nil { + slog.ErrorContext(ctx, "Failed to check sign-in browsers", slog.Any("error", err)) + return false + } + if len(agents) == 0 { + return false + } + + family := browserFamily(userAgent) + for _, agent := range agents { + if browserFamily(agent) == family { + return false + } + } + return true +} + +func browserFamily(userAgent string) string { + ua := userAgentParser.Parse(userAgent) + return ua.Name + "|" + ua.OS +} + // SendSignInNotification sends only after the caller has completed the login successfully func (s *service) SendSignInNotification(ctx context.Context, result SignInResult) { if !result.Created || !result.Notify { diff --git a/backend/internal/auditlogs/signin_test.go b/backend/internal/auditlogs/signin_test.go index 68d6c886..aaba852b 100644 --- a/backend/internal/auditlogs/signin_test.go +++ b/backend/internal/auditlogs/signin_test.go @@ -228,3 +228,37 @@ func TestSignInNotificationModes(t *testing.T) { } } } + +func TestSignInFlagsNewBrowser(t *testing.T) { + const firefoxOld = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:130.0) Gecko/20100101 Firefox/130.0" + const firefoxNew = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:144.0) Gecko/20100101 Firefox/144.0" + const chrome = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" + + for _, tt := range []struct { + name string + previous []AuditLog + agent string + want bool + }{ + {name: "first sign-in of the account", agent: chrome}, + {name: "updated version of a known browser", previous: []AuditLog{{Event: EventSignIn, UserAgent: firefoxOld}}, agent: firefoxNew}, + {name: "another browser", previous: []AuditLog{{Event: EventSignIn, UserAgent: firefoxOld}}, agent: chrome, want: true}, + {name: "login code history counts", previous: []AuditLog{{Event: EventOneTimeAccessTokenSignIn, UserAgent: chrome}}, agent: chrome}, + {name: "events that are not sign-ins don't count", previous: []AuditLog{{Event: EventPasskeyAdded, UserAgent: firefoxOld}}, agent: chrome}, + } { + t.Run(tt.name, func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + s := newService(db, nil, signInLocationResolver{}, nil, browserTokenStub{}) + user := model.User{Base: model.Base{ID: "user"}, Username: "user"} + require.NoError(t, db.Create(&user).Error) + for _, entry := range tt.previous { + _, created := s.Create(t.Context(), entry.Event, "192.0.2.1", entry.UserAgent, user.ID, Data{}, db) + require.True(t, created) + } + + result := s.CreateSignIn(t.Context(), EventSignIn, "192.0.2.1", tt.agent, user.ID, "", db, appconfig.LoginNotificationDisabled) + require.True(t, result.Created) + require.Equal(t, tt.want, result.AuditLog.Data["newBrowser"] == "true") + }) + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a7578401..2c2ea08a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -12,11 +12,12 @@ "select_a_date": "Select a date", "profile_picture": "Profile picture", "no_items_found": "No items found", - "select_items": "Select items...", - "search": "Search...", + "select_items": "Select items…", + "search": "Search…", "copied": "Copied", "something_went_wrong": "Something went wrong", "go_back_to_home": "Go back to home", + "page_not_found": "This page doesn’t exist.", "logo": "Logo", "one_hour": "1 hour", "twelve_hours": "12 hours", @@ -62,8 +63,8 @@ "sqlite_network_storage_warning": "We detected that your SQLite database is stored on a network-attached drive like NFS, SMB, or FUSE. This is unsupported and can lead to your database getting corrupted.", "time": "Time", "event": "Event", - "approximate_location": "Approximate Location", - "ip_address": "IP Address", + "approximate_location": "Approximate location", + "ip_address": "IP address", "device": "Device", "client": "Client", "unknown": "Unknown", @@ -87,10 +88,7 @@ "username_must_end_with": "Username must end with an alphanumeric character", "added_on": "Added on", "rename": "Rename", - "delete": "Delete", - "are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?", "passkey_deleted_successfully": "Passkey deleted successfully", - "delete_passkey_name": "Delete {passkeyName}", "passkey_name_updated_successfully": "Passkey name updated successfully", "name_passkey": "Name passkey", "name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.", @@ -128,7 +126,6 @@ "role": "Role", "admin": "Admin", "user": "User", - "toggle_menu": "Toggle menu", "back": "Back", "custom_claims": "Custom claims", "add_group": "Add group", @@ -150,13 +147,11 @@ "oidc_client_name": "OIDC Client {name}", "client_id": "Client ID", "client_secret": "Client secret", - "unrestrict": "Unrestrict", - "user_groups_restriction_updated_successfully": "User groups restriction updated successfully", "favicon": "Favicon", "email_logo": "Email logo", "language": "Language", "reset_profile_picture_question": "Reset profile picture?", - "this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image and reset the profile picture to default. Do you want to continue?", + "this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "The uploaded image is removed and the default picture is shown instead.", "reset": "Reset", "reset_to_default": "Reset to default", "reset_setting_to_default": "Reset {setting} to default", @@ -171,7 +166,7 @@ "allow_all_users_question": "Allow all users to sign in to {name}?", "allow_all_users_consequence": "Its allowed groups are removed, and anyone with a {appName} account can sign in.", "audit_event_sign_in": "Sign in", - "audit_event_token_sign_in": "Token Sign In", + "audit_event_token_sign_in": "Token sign in", "audit_event_remote_sign_in": "Remote sign in", "audit_event_client_authorization": "Client authorization", "audit_event_new_client_authorization": "New client authorization", @@ -222,7 +217,7 @@ "select_user": "Select user", "usage_limit": "Usage limit", "expires": "Expires", - "signup": "Sign Up", + "signup": "Sign up", "skip_for_now": "Skip for now", "create_signup_token": "Create signup token", "expired": "Expired", @@ -248,17 +243,14 @@ "light": "Light", "dark": "Dark", "system": "System", - "unrestrict_oidc_client": "Unrestrict {clientName}", - "confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client {#b}{clientName}{/b}? This will remove all group assignments for this client and any user will be able to sign in.", "yes": "Yes", "no": "No", "scim_provisioning": "SCIM provisioning", "scim_endpoint": "SCIM endpoint", "scim_token": "SCIM token", "scim_sync_failed": "SCIM sync failed. Check the server logs for more information.", - "scim_sync_successful": "The SCIM sync has been completed successfully.", - "save_and_sync": "Save and Sync", - "scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?", + "save_and_sync": "Save and sync", + "scim_save_changes_description": "You have to save the changes before starting a SCIM sync.", "scopes": "Scopes", "api_key_renewed": "API key renewed", "app_config_home_page": "Home page", @@ -278,27 +270,26 @@ "replay_protection": "Replay protection", "apis": "APIs", "add_api": "Add API", - "api_resource": "Resource", - "api_resource_description": "A unique URI that identifies this API resource. Clients request it with the resource parameter. It can't be changed later.", + "api_resource_description": "Clients request it with the resource parameter. It can’t be changed later.", "api_permissions": "Permissions", - "api_permissions_description": "The permissions (scopes) that clients can request for this API.", + "api_permissions_description": "The scopes clients can request for this API.", "add_permission": "Add permission", + "no_permissions": "No permissions", "api_access": "API access", - "api_access_updated_successfully": "API access updated successfully", "access_an_api_on_your_behalf": "Access an API on your behalf", "allow_all_metadata_document_clients": "Allow all metadata document clients", - "allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of users.", + "allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of people.", "granted_permissions": "Granted permissions", "granted_through_cimd_access": "Granted because this API allows all metadata document clients", "access_granted_through_cimd_access": "This API allows all metadata document clients, so the access granted that way is shown here but can only be changed on the API itself.", "access": "Access", "select_a_client_to_grant_access_to_this_api": "Select the client that should get access to this API.", - "revoke_access_to_name": "Revoke access to {name}", - "are_you_sure_you_want_to_revoke_the_access_of_this_client_to_the_api": "Are you sure you want to revoke this client's access to the API? Its access to other APIs is kept.", "select_an_api_this_client_should_access": "Select the API this client should get access to.", "granted_to_all_cimd_clients": "Granted to all metadata document clients", "sign_in_with_another_device": "Sign in with another device", "device_login_qr_code": "Device login QR code", + "show_qr_code": "Show QR code", + "hide_qr_code": "Hide QR code", "deny": "Deny", "approve": "Approve", "visit_and_enter": "Visit {url} and enter:", @@ -1124,5 +1115,15 @@ "palette_hint_navigate": "to move", "palette_hint_open": "to open", "palette_hint_close": "to close", - "hide_advanced_settings": "Hide advanced settings" + "date": "Date", + "last_24_hours": "Last 24 hours", + "last_n_days": "Last {count} days", + "custom_range": "Custom range", + "date_from": "From", + "date_to": "To", + "apply": "Apply", + "date_range_order": "The end date can’t be before the start date.", + "new_browser": "New browser", + "new_browser_hint": "First sign-in from this browser", + "page_not_found_title": "Page not found" } diff --git a/frontend/src/lib/components/audit-log-date-filter.svelte b/frontend/src/lib/components/audit-log-date-filter.svelte new file mode 100644 index 00000000..7b59f1e1 --- /dev/null +++ b/frontend/src/lib/components/audit-log-date-filter.svelte @@ -0,0 +1,108 @@ + + + + + {m.date()}: + {label} + ({zone}) + + + + {#each [{ id: 'any', label: m.any(), ms: 0 }, ...presets] as preset (preset.id)} + + {/each} +
+

{m.custom_range()}

+
+ + +
+ {#if invalid}

{m.date_range_order()}

{/if} + +
+
+
diff --git a/frontend/src/lib/components/audit-log-list.svelte b/frontend/src/lib/components/audit-log-list.svelte index 8ce148a3..17c7de9d 100644 --- a/frontend/src/lib/components/audit-log-list.svelte +++ b/frontend/src/lib/components/audit-log-list.svelte @@ -11,7 +11,7 @@ import { getLocale } from '#lib/paraglide/runtime.js'; import AuditLogService from '#lib/services/audit-log-service.ts'; import appConfigStore from '#lib/stores/application-configuration-store.ts'; - import type { AuditLog, AuditLogFilter } from '#lib/types/audit-log.type.ts'; + import type { AuditLog, AuditLogDateRange, AuditLogFilter } from '#lib/types/audit-log.type.ts'; import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts'; import { translateAuditLogEvent } from '#lib/utils/audit-log-translator.ts'; import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts'; @@ -22,10 +22,14 @@ let { isAdmin = false, - filters + filters, + dateRange = {}, + onClearFilters }: { isAdmin?: boolean; filters?: AuditLogFilter; + dateRange?: AuditLogDateRange; + onClearFilters?: () => void; } = $props(); const auditLogService = new AuditLogService(); @@ -45,6 +49,8 @@ void filters?.event; void filters?.location; void filters?.clientName; + void dateRange.from; + void dateRange.to; untrack(() => { requestOptions.pagination!.page = 1; refresh(); @@ -55,8 +61,11 @@ isRefreshing = true; try { logs = isAdmin - ? await auditLogService.listAllLogs({ ...requestOptions, filters: wrapFilters(filters) }) - : await auditLogService.list(requestOptions); + ? await auditLogService.listAllLogs( + { ...requestOptions, filters: wrapFilters(filters) }, + dateRange + ) + : await auditLogService.list(requestOptions, dateRange); } finally { isRefreshing = false; } @@ -250,7 +259,10 @@ {#if isAdmin} - {log.event} + {log.event} + {/if} + {#if log.data?.newBrowser === 'true'} + {m.new_browser()} {/if} {#if via}{via}{/if} @@ -307,7 +319,12 @@ {/if} {@render detailItem(m.ip_address(), log.ipAddress, m.copy_ip_address())} {@render detailItem(m.approximate_location(), formatLocation(log))} - {@render detailItem(m.device(), log.device)} + {@render detailItem( + m.device(), + log.data?.newBrowser === 'true' + ? `${log.device} · ${m.new_browser_hint()}` + : log.device + )} {@render detailItem(m.event_id(), log.id, m.copy_event_id())}
diff --git a/frontend/src/lib/services/audit-log-service.ts b/frontend/src/lib/services/audit-log-service.ts index aa0abf08..aaf2f12d 100644 --- a/frontend/src/lib/services/audit-log-service.ts +++ b/frontend/src/lib/services/audit-log-service.ts @@ -1,15 +1,15 @@ -import type { AuditLog } from '#lib/types/audit-log.type.ts'; +import type { AuditLog, AuditLogDateRange } from '#lib/types/audit-log.type.ts'; import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts'; import APIService from './api-service'; export default class AuditLogService extends APIService { - list = async (options?: ListRequestOptions) => { - const res = await this.api.get('/audit-logs', { params: options }); + list = async (options?: ListRequestOptions, range?: AuditLogDateRange) => { + const res = await this.api.get('/audit-logs', { params: { ...options, ...range } }); return res.data as Paginated; }; - listAllLogs = async (options?: ListRequestOptions) => { - const res = await this.api.get('/audit-logs/all', { params: options }); + listAllLogs = async (options?: ListRequestOptions, range?: AuditLogDateRange) => { + const res = await this.api.get('/audit-logs/all', { params: { ...options, ...range } }); return res.data as Paginated; }; diff --git a/frontend/src/lib/types/audit-log.type.ts b/frontend/src/lib/types/audit-log.type.ts index 2da87e36..24ef14dd 100644 --- a/frontend/src/lib/types/audit-log.type.ts +++ b/frontend/src/lib/types/audit-log.type.ts @@ -12,6 +12,12 @@ export type AuditLog = { data: any; }; +// RFC 3339 bounds of the date filter: from is inclusive, to exclusive, and a missing bound is open +export type AuditLogDateRange = { + from?: string; + to?: string; +}; + export type AuditLogFilter = { userID: string; event: string; diff --git a/frontend/src/routes/settings/audit-log/+page.svelte b/frontend/src/routes/settings/audit-log/+page.svelte index 0ab20b82..872ea3f7 100644 --- a/frontend/src/routes/settings/audit-log/+page.svelte +++ b/frontend/src/routes/settings/audit-log/+page.svelte @@ -1,7 +1,11 @@ @@ -10,6 +14,8 @@ -
- +
+
+ + diff --git a/frontend/src/routes/settings/audit-log/global/+page.svelte b/frontend/src/routes/settings/audit-log/global/+page.svelte index a7ceb2bf..7d088920 100644 --- a/frontend/src/routes/settings/audit-log/global/+page.svelte +++ b/frontend/src/routes/settings/audit-log/global/+page.svelte @@ -1,22 +1,22 @@