mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 12:09:05 +01:00
feat: filter audit logs by date and flag sign-ins from new browsers
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+27
-26
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
import { Input } from '#lib/components/ui/input/index.ts';
|
||||
import * as Popover from '#lib/components/ui/popover/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import { getLocale } from '#lib/paraglide/runtime.js';
|
||||
import type { AuditLogDateRange } from '#lib/types/audit-log.type.ts';
|
||||
import { preventDefault } from '#lib/utils/event-util.ts';
|
||||
import { Check, ChevronDown } from '@lucide/svelte';
|
||||
|
||||
let { range = $bindable({}) }: { range: AuditLogDateRange } = $props();
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const presets = [
|
||||
{ id: '24h', label: m.last_24_hours(), ms: DAY },
|
||||
{ id: '7d', label: m.last_n_days({ count: 7 }), ms: 7 * DAY },
|
||||
{ id: '30d', label: m.last_n_days({ count: 30 }), ms: 30 * DAY },
|
||||
{ id: '90d', label: m.last_n_days({ count: 90 }), ms: 90 * DAY }
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
let selected = $state('any');
|
||||
let label = $state<string>(m.any());
|
||||
let from = $state('');
|
||||
let to = $state('');
|
||||
const invalid = $derived(!!from && !!to && to < from);
|
||||
|
||||
// Times are shown in the browser's zone, so the trigger names it
|
||||
const zone =
|
||||
new Intl.DateTimeFormat(getLocale(), { timeZoneName: 'shortOffset' })
|
||||
.formatToParts(new Date())
|
||||
.find((p) => p.type === 'timeZoneName')?.value ?? '';
|
||||
|
||||
function choose(preset: (typeof presets)[number]) {
|
||||
selected = preset.id;
|
||||
label = preset.label;
|
||||
range = preset.ms ? { from: new Date(Date.now() - preset.ms).toISOString() } : {};
|
||||
open = false;
|
||||
}
|
||||
|
||||
function day(value: string) {
|
||||
const date = new Date(`${value}T00:00`);
|
||||
return date.toLocaleDateString(getLocale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// A custom range covers whole local days, so the end is the start of the day after the To date
|
||||
function applyCustom() {
|
||||
if (invalid || (!from && !to)) return;
|
||||
selected = 'custom';
|
||||
label =
|
||||
from && from === to ? day(from) : `${from ? day(from) : ''} – ${to ? day(to) : ''}`.trim();
|
||||
const startOf = (value: string, addDays = 0) =>
|
||||
new Date(new Date(`${value}T00:00`).getTime() + addDays * DAY).toISOString();
|
||||
range = { from: from ? startOf(from) : undefined, to: to ? startOf(to, 1) : undefined };
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
aria-label={m.facet_filter_label({ name: m.date(), value: label })}
|
||||
class="hover:bg-fill-hover aria-expanded:bg-fill-selected focus-visible:outline-ring text-control flex h-7 shrink-0 items-center gap-1 rounded-xs pr-1.5 pl-2 whitespace-nowrap transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
<span class="text-fg-muted">{m.date()}:</span>
|
||||
<span class={selected === 'any' ? 'text-fg-muted' : 'text-fg font-medium'}>{label}</span>
|
||||
<span class="text-fg-muted">({zone})</span>
|
||||
<ChevronDown class="text-fg-muted size-3" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" class="w-72 gap-0 p-1">
|
||||
{#each [{ id: 'any', label: m.any(), ms: 0 }, ...presets] as preset (preset.id)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected === preset.id}
|
||||
class="hover:bg-fg/6 focus-visible:outline-ring text-control flex h-9 w-full items-center gap-2 rounded-sm px-2.5 text-left focus-visible:outline-2 focus-visible:-outline-offset-2"
|
||||
onclick={() => choose(preset)}
|
||||
>
|
||||
<span class="flex size-4 items-center justify-center">
|
||||
{#if selected === preset.id}<Check class="size-4" />{/if}
|
||||
</span>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
<form
|
||||
class="mt-1 flex flex-col gap-3 border-t px-2.5 pt-3 pb-2"
|
||||
onsubmit={preventDefault(applyCustom)}
|
||||
>
|
||||
<p class="text-caption text-fg-muted font-medium">{m.custom_range()}</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-caption text-fg-muted flex flex-col gap-1">
|
||||
{m.date_from()}
|
||||
<Input type="date" bind:value={from} aria-invalid={invalid} />
|
||||
</label>
|
||||
<label class="text-caption text-fg-muted flex flex-col gap-1">
|
||||
{m.date_to()}
|
||||
<Input type="date" bind:value={to} aria-invalid={invalid} />
|
||||
</label>
|
||||
</div>
|
||||
{#if invalid}<p class="text-caption text-danger-fg">{m.date_range_order()}</p>{/if}
|
||||
<Button type="submit" size="sm" variant="secondary" disabled={invalid || (!from && !to)}>
|
||||
{m.apply()}
|
||||
</Button>
|
||||
</form>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -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 @@
|
||||
</span>
|
||||
<span class="text-caption text-fg-muted flex min-w-0 items-center gap-x-1.5">
|
||||
{#if isAdmin}
|
||||
<Badge variant="mono">{log.event}</Badge>
|
||||
<Badge variant="mono" class="max-sm:hidden">{log.event}</Badge>
|
||||
{/if}
|
||||
{#if log.data?.newBrowser === 'true'}
|
||||
<Badge variant="warning" title={m.new_browser_hint()}>{m.new_browser()}</Badge>
|
||||
{/if}
|
||||
{#if via}<span class="shrink-0 max-sm:hidden">{via}</span>{/if}
|
||||
<!-- Below xl the Device column folds into this line so the sentence keeps its width -->
|
||||
@@ -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())}
|
||||
</dl>
|
||||
<details class="group/raw mt-4">
|
||||
|
||||
@@ -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<AuditLog>;
|
||||
};
|
||||
|
||||
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<AuditLog>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import AuditLogDateFilter from '#lib/components/audit-log-date-filter.svelte';
|
||||
import AuditLogList from '#lib/components/audit-log-list.svelte';
|
||||
import PageHeader from '#lib/components/page-header.svelte';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import type { AuditLogDateRange } from '#lib/types/audit-log.type.ts';
|
||||
|
||||
let dateRange: AuditLogDateRange = $state({});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -10,6 +14,8 @@
|
||||
|
||||
<PageHeader title={m.activity()} description={m.activity_description()} />
|
||||
|
||||
<div class="mt-4">
|
||||
<AuditLogList />
|
||||
<div role="group" aria-label={m.filters()} class="mt-6 -mb-1 flex items-center gap-1 py-1">
|
||||
<AuditLogDateFilter bind:range={dateRange} />
|
||||
</div>
|
||||
|
||||
<AuditLogList {dateRange} />
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts">
|
||||
import AuditLogDateFilter from '#lib/components/audit-log-date-filter.svelte';
|
||||
import AuditLogList from '#lib/components/audit-log-list.svelte';
|
||||
import PageHeader from '#lib/components/page-header.svelte';
|
||||
import AdvancedTableFilter from '#lib/components/table/advanced-table-filter.svelte';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import AuditLogService from '#lib/services/audit-log-service.ts';
|
||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||
import type { AuditLogFilter } from '#lib/types/audit-log.type.ts';
|
||||
import type { AuditLogDateRange, AuditLogFilter } from '#lib/types/audit-log.type.ts';
|
||||
import { eventTypes } from '#lib/utils/audit-log-translator.ts';
|
||||
import { revealFocused } from '#lib/utils/event-util.ts';
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
let filters: AuditLogFilter = $state({
|
||||
userID: '',
|
||||
event: '',
|
||||
location: '',
|
||||
clientName: ''
|
||||
});
|
||||
const noFilters: AuditLogFilter = { userID: '', event: '', location: '', clientName: '' };
|
||||
let filters = $state({ ...noFilters });
|
||||
|
||||
let dateRange: AuditLogDateRange = $state({});
|
||||
let filterRow = $state<HTMLElement>();
|
||||
let userOptions = $state<{ label: string; value: string }[]>([]);
|
||||
let clientOptions = $state<{ label: string; value: string }[]>([]);
|
||||
|
||||
@@ -67,8 +67,10 @@
|
||||
bind:this={filterRow}
|
||||
role="group"
|
||||
aria-label={m.filters()}
|
||||
class="mt-6 -mb-1 flex items-center gap-1 py-1 max-sm:[scrollbar-width:none] max-sm:overflow-x-auto sm:flex-wrap"
|
||||
onfocusin={revealFocused}
|
||||
class="scroll-fade-x -mx-1 mt-6 -mb-1 flex scroll-px-1 items-center gap-1 p-1 max-sm:[scrollbar-width:none] max-sm:overflow-x-auto sm:flex-wrap"
|
||||
>
|
||||
<AuditLogDateFilter bind:range={dateRange} />
|
||||
{#each facets as facet (facet.key)}
|
||||
<AdvancedTableFilter
|
||||
title={facet.title}
|
||||
@@ -79,4 +81,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<AuditLogList isAdmin {filters} />
|
||||
<!-- The date range is never cleared, since the date filter keeps its own selection -->
|
||||
<!-- Focus moves to the filters because the action removes itself -->
|
||||
<AuditLogList
|
||||
isAdmin
|
||||
{filters}
|
||||
{dateRange}
|
||||
onClearFilters={Object.values(filters).some(Boolean)
|
||||
? () => {
|
||||
filters = { ...noFilters };
|
||||
filterRow?.querySelector('button')?.focus();
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user