Subject: [PATCH links] Adding audit log views for the following:

From: Peter Sanchez <[email-masked]>
Date: Wed, 19 Mar 2025 18:20:58 -0600

- Personal account level
- Organization level
- Link listing level

Changelog-added: Handlers to view audit logs at various levels.
---
 accounts/routes.go                            |  14 +++
 api/graph/schema.resolvers.go                 |  57 +++++++++-
 cmd/migrations.go                             |   7 ++
 core/import.go                                |   2 +
 core/routes.go                                |  24 ++++
 helpers.go                                    | 103 ++++++++++++++++++
 list/routes.go                                |  29 +++++
 .../0006_update_auditlog_metadata.down.sql    |   2 +
 .../0006_update_auditlog_metadata.up.sql      |  10 ++
 templates/auditlog.html                       |  47 ++++++++
 templates/listing_list.html                   |   1 +
 templates/org_list.html                       |   1 +
 templates/settings.html                       |   1 +
 13 files changed, 292 insertions(+), 6 deletions(-)
 create mode 100644 migrations/0006_update_auditlog_metadata.down.sql
 create mode 100644 migrations/0006_update_auditlog_metadata.up.sql
 create mode 100644 templates/auditlog.html

diff --git a/accounts/routes.go b/accounts/routes.go
index d9350ce..9bb2beb 100644
--- a/accounts/routes.go
+++ b/accounts/routes.go
@@ -57,6 +57,7 @@ func (s *Service) RegisterRoutes() {
 	gservice.RegisterRoutes()
 	s.Group.Use(auth.AuthRequired())
 	s.Group.GET("/settings", s.Settings).Name = s.RouteName("settings")
+	s.Group.GET("/settings/log", s.UserLog).Name = s.RouteName("settings_log")
 	s.Group.GET("/profile", s.EditProfile).Name = s.RouteName("profile_edit")
 	s.Group.POST("/profile", s.EditProfile).Name = s.RouteName("profile_edit_post")
 }
@@ -229,6 +230,7 @@ func (s *Service) Settings(c echo.Context) error {
 	pd.Data["edit_profile"] = lt.Translate("Edit profile")
 	pd.Data["manage"] = lt.Translate("Manage Your Organizations")
 	pd.Data["upgrade"] = lt.Translate("Upgrade Org")
+	pd.Data["logs"] = lt.Translate("View Audit Logs")
 
 	user := gctx.User.(*models.User)
 	langTrans := map[string]string{
@@ -278,6 +280,18 @@ func (s *Service) Settings(c echo.Context) error {
 	return s.Render(c, http.StatusOK, "settings.html", gmap)
 }
 
+// UserLog will show the user their auditlog history (for all orgs)
+func (s *Service) UserLog(c echo.Context) error {
+	gctx := c.(*server.Context)
+	user := gctx.User.(*models.User)
+
+	gmap, err := links.FetchAuditLogs(c, int(user.ID), "", 0, 0)
+	if err != nil {
+		return err
+	}
+	return s.Render(c, http.StatusOK, "auditlog.html", gmap)
+}
+
 // CompleteRegister is used when a user has been invited to an organization but does not
 // yet have an account. For normal user registration "completion", see
 // gobwebs/accounts/routes.go
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 6a3ae3b..a00cc26 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -190,6 +190,7 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga
 
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -384,6 +385,7 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input *model.
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -566,6 +568,7 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		userID,
@@ -772,6 +775,7 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	ltype := models.LOG_BOOKMARK_UPDATED
 	ldet := "bookmark"
 	if orgLink.Type == models.NoteType {
@@ -877,6 +881,7 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model.
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	ltype := models.LOG_BOOKMARK_DELETED
 	ldet := "bookmark"
 	if link.Type == models.NoteType {
@@ -1199,6 +1204,7 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp
 
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["user_id"] = user.ID
 	err = models.RecordAuditLog(
 		ctx,
@@ -1302,6 +1308,7 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema
 
 		mdata := make(map[string]any)
 		mdata["org_id"] = org.ID
+		mdata["org_slug"] = org.Slug
 		mdata["user_id"] = duser.ID
 		err = models.RecordAuditLog(
 			ctx,
@@ -1430,6 +1437,7 @@ func (r *mutationResolver) ConfirmMember(ctx context.Context, key string) (*mode
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -1904,6 +1912,7 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = personalOrg.ID
+	mdata["org_slug"] = personalOrg.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -2057,6 +2066,7 @@ func (r *mutationResolver) AddDomain(ctx context.Context, input model.DomainInpu
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["domain_id"] = domain.ID
 	err = models.RecordAuditLog(
 		ctx,
@@ -2141,6 +2151,7 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = domain.OrgID
+	mdata["org_slug"] = domain.OrgSlug
 	mdata["domain_id"] = domain.ID
 	err = models.RecordAuditLog(
 		ctx,
@@ -2357,6 +2368,7 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
 
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -2552,6 +2564,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -2619,6 +2632,7 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model.
 
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -2935,7 +2949,9 @@ func (r *mutationResolver) AddListing(ctx context.Context, input *model.AddListi
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["list_id"] = listing.ID
+	mdata["list_slug"] = listing.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -3037,7 +3053,9 @@ func (r *mutationResolver) AddListingLink(ctx context.Context, input *model.AddL
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["list_id"] = listing.ID
+	mdata["list_slug"] = listing.Slug
 	mdata["list_link_id"] = listingLink.ID
 	err = models.RecordAuditLog(
 		ctx,
@@ -3382,7 +3400,9 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["list_id"] = listing.ID
+	mdata["list_slug"] = listing.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -3458,6 +3478,12 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U
 		return nil, nil
 	}
 
+	listing := &models.Listing{ID: listingLink.ListingID}
+	err = listing.Load(ctx)
+	if err != nil {
+		return nil, err
+	}
+
 	listingLink.Title = input.Title
 	listingLink.LinkOrder = input.LinkOrder
 	listingLink.URL = input.URL
@@ -3472,7 +3498,8 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
-	mdata["list_id"] = listingLink.ListingID
+	mdata["org_slug"] = org.Slug
+	mdata["list_slug"] = listing.Slug
 	mdata["list_link_id"] = listingLink.ID
 	err = models.RecordAuditLog(
 		ctx,
@@ -3542,7 +3569,9 @@ func (r *mutationResolver) DeleteListing(ctx context.Context, id int) (*model.De
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["list_id"] = listing.ID
+	mdata["list_slug"] = listing.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -3604,6 +3633,12 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode
 		return nil, nil
 	}
 
+	listing := &models.Listing{ID: listingLink.ID}
+	err = listing.Load(ctx)
+	if err != nil {
+		return nil, err
+	}
+
 	deletedID := strconv.Itoa(listingLink.ID)
 	err = listingLink.Delete(ctx)
 	if err != nil {
@@ -3613,14 +3648,16 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["list_id"] = listingLink.ListingID
+	mdata["list_slug"] = listing.Slug
 	mdata["list_link_id"] = listingLink.ID
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
 		c.RealIP(),
 		models.LOG_LIST_LINK_DELETED,
-		fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
+		fmt.Sprintf("Deleted listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
 		mdata,
 	)
 	if err != nil {
@@ -3905,6 +3942,7 @@ func (r *mutationResolver) AddQRCode(ctx context.Context, input model.AddQRCodeI
 
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["qrcode_id"] = qr.ID
 	mdata["qrcode_type"] = qr.CodeType
 	err = models.RecordAuditLog(
@@ -3975,6 +4013,7 @@ func (r *mutationResolver) DeleteQRCode(ctx context.Context, id int) (*model.Del
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	mdata["qrcode_id"] = qrCode.ID
 	mdata["qrcode_type"] = qrCode.CodeType
 	err = models.RecordAuditLog(
@@ -4048,6 +4087,7 @@ func (r *mutationResolver) Follow(ctx context.Context, orgSlug string) (*model.F
 		c := server.EchoForContext(ctx)
 		mdata := make(map[string]any)
 		mdata["org_id"] = org.ID
+		mdata["org_slug"] = org.Slug
 		err = models.RecordAuditLog(
 			ctx,
 			int(user.ID),
@@ -4125,6 +4165,7 @@ func (r *mutationResolver) Unfollow(ctx context.Context, orgSlug string) (*model
 	c := server.EchoForContext(ctx)
 	mdata := make(map[string]any)
 	mdata["org_id"] = org.ID
+	mdata["org_slug"] = org.Slug
 	err = models.RecordAuditLog(
 		ctx,
 		int(user.ID),
@@ -6652,10 +6693,13 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI
 		return nil, nil
 	}
 
-	var org *models.Organization
-	var err error
+	var (
+		org *models.Organization
+		err error
+	)
 	opts := &database.FilterOptions{
-		Filter: sq.And{},
+		Filter:  sq.And{},
+		OrderBy: "al.id DESC",
 	}
 
 	if input.OrgSlug != nil && *input.OrgSlug != "" {
@@ -6781,8 +6825,9 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI
 	} else if input.Before != nil {
 		opts.Filter = sq.And{
 			opts.Filter,
-			sq.Expr("ol.id >= ?", input.Before.Before),
+			sq.Expr("al.id >= ?", input.Before.Before),
 		}
+		opts.OrderBy = "al.id ASC"
 		numElements = input.Before.Limit
 	}
 
diff --git a/cmd/migrations.go b/cmd/migrations.go
index 05c7bc5..43f3de1 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -59,5 +59,12 @@ func GetMigrations() []migrate.Migration {
 			0,
 			links.MigrateFS,
 		),
+		migrate.FSFileMigration(
+			"0006_update_auditlog_metadata",
+			"migrations/0006_update_auditlog_metadata.up.sql",
+			"migrations/0006_update_auditlog_metadata.down.sql",
+			0,
+			links.MigrateFS,
+		),
 	}
 }
diff --git a/core/import.go b/core/import.go
index 27c844d..b1a1129 100644
--- a/core/import.go
+++ b/core/import.go
@@ -478,6 +478,7 @@ func ImportFromPinBoard(ctx context.Context, path string,
 	if totalCount > 0 {
 		mdata := make(map[string]any)
 		mdata["org_id"] = org.ID
+		mdata["org_slug"] = org.Slug
 		err := models.RecordAuditLog(
 			ctx,
 			int(user.ID),
@@ -625,6 +626,7 @@ func ImportFromHTML(ctx context.Context, path string,
 	if listlen > 0 {
 		mdata := make(map[string]any)
 		mdata["org_id"] = org.ID
+		mdata["org_slug"] = org.Slug
 		err := models.RecordAuditLog(
 			ctx,
 			int(user.ID),
diff --git a/core/routes.go b/core/routes.go
index 3afa8f6..661c1d9 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -83,6 +83,7 @@ func (s *Service) RegisterRoutes() {
 	s.Group.GET("/:slug/import", s.ImportData).Name = s.RouteName("import_data")
 	s.Group.POST("/:slug/import", s.ImportData).Name = s.RouteName("import_data")
 	s.Group.GET("/:slug/integrations", s.Integrations).Name = s.RouteName("integrations")
+	s.Group.GET("/:slug/log", s.OrgLog).Name = s.RouteName("org_log")
 
 	s.Group.GET("/home", s.OrgLinksList).Name = s.RouteName("home_link_list")
 	s.Group.GET("/add", s.OrgLinksCreate).Name = s.RouteName("link_create")
@@ -368,6 +369,28 @@ func (s *Service) FeatureTour(c echo.Context) error {
 	return s.Render(c, http.StatusOK, "feature_tour.html", gmap)
 }
 
+func (s *Service) OrgLog(c echo.Context) error {
+	gctx := c.(*server.Context)
+	user := gctx.User.(*models.User)
+	slug := c.Param("slug")
+	userID := int(user.ID)
+
+	org, err := user.GetOrgsSlug(c.Request().Context(), models.OrgUserPermissionAdminWrite, slug)
+	if err != nil {
+		return err
+	}
+	if org != nil {
+		userID = 0 // Admin privileges can view all audit logs for org
+	}
+
+	gmap, err := links.FetchAuditLogs(c, userID, slug, 0, 0)
+	if err != nil {
+		return err
+	}
+
+	return s.Render(c, http.StatusOK, "auditlog.html", gmap)
+}
+
 func (s *Service) DomainList(c echo.Context) error {
 	gctx := c.(*server.Context)
 	user := gctx.User.(*models.User)
@@ -686,6 +709,7 @@ func (s *Service) OrgList(c echo.Context) error {
 	pd.Data["import"] = lt.Translate("Import")
 	pd.Data["integrations"] = lt.Translate("Integrations")
 	pd.Data["payment_history"] = lt.Translate("Payment History")
+	pd.Data["logs"] = lt.Translate("View Audit Logs")
 
 	// If we want to highlight an org based on a given domain
 	var dOrgSlug string
diff --git a/helpers.go b/helpers.go
index fd41437..e1da322 100644
--- a/helpers.go
+++ b/helpers.go
@@ -38,6 +38,7 @@ import (
 	"golang.org/x/text/language"
 	"golang.org/x/time/rate"
 	"netlandish.com/x/gobwebs"
+	auditlog "netlandish.com/x/gobwebs-auditlog"
 	"netlandish.com/x/gobwebs/config"
 	"netlandish.com/x/gobwebs/core"
 	"netlandish.com/x/gobwebs/crypto"
@@ -1242,3 +1243,105 @@ func TagAbuseRedirect(c echo.Context) error {
 	}
 	return nil
 }
+
+// AuditLogResponse is a struct for auditlog gql query response storage
+type AuditLogResponse struct {
+	AuditLogs struct {
+		Result   []auditlog.AuditLog `json:"result"`
+		PageInfo struct {
+			Cursor      string
+			HasNextPage bool
+			HasPrevPage bool
+		} `json:"pageInfo"`
+	} `json:"getAuditLogs"`
+}
+
+// FetchAuditLogs is helper to run a query fetching audit logs depending on various
+// conditions.
+func FetchAuditLogs(c echo.Context, userID int,
+	orgSlug string, listID int, limit int) (gobwebs.Map, error) {
+	op := gqlclient.NewOperation(
+		`query GetAuditLogs($userId: Int, $slug: String, $listingId: Int, $after: Cursor, 
+			$before: Cursor, $limit: Int) {
+			getAuditLogs(input: {
+				userId: $userId,
+				orgSlug: $slug,
+				listingId: $listingId,
+				after: $after,
+				before: $before,
+				limit: $limit,
+			}) {
+				result {
+					userId
+					ipAddress
+					eventType
+					details
+					metadata
+					createdOn
+				}
+				pageInfo {
+					cursor
+					hasPrevPage
+					hasNextPage
+				}
+			}
+		}`)
+
+	if userID > 0 {
+		op.Var("userId", userID)
+	}
+	if orgSlug != "" {
+		op.Var("slug", orgSlug)
+	}
+	if listID > 0 {
+		op.Var("listingId", listID)
+	}
+	if limit > 0 {
+		op.Var("limit", limit)
+	}
+	if c.QueryParam("next") != "" {
+		op.Var("after", c.QueryParam("next"))
+	} else if c.QueryParam("prev") != "" {
+		op.Var("before", c.QueryParam("prev"))
+	}
+
+	var result AuditLogResponse
+	err := Execute(LangContext(c), op, &result)
+	if err != nil {
+		return nil, err
+	}
+
+	gctx := c.(*server.Context)
+	user := gctx.User.(*models.User)
+	if c.QueryParam("prev") != "" {
+		slices.Reverse(result.AuditLogs.Result)
+	}
+
+	lt := localizer.GetSessionLocalizer(c)
+	pd := localizer.NewPageData(lt.Translate("Audit Log"))
+	pd.Data["ip_address"] = lt.Translate("IP Address")
+	pd.Data["org"] = lt.Translate("Organization")
+	pd.Data["listing"] = lt.Translate("Link Listing")
+	pd.Data["event_type"] = lt.Translate("Action")
+	pd.Data["details"] = lt.Translate("Details")
+	pd.Data["timestamp"] = lt.Translate("Timestamp")
+	pd.Data["no_logs"] = lt.Translate("No audit logs to display")
+	pd.Data["next"] = lt.Translate("Next")
+	pd.Data["prev"] = lt.Translate("Prev")
+	gmap := gobwebs.Map{
+		"pd":      pd,
+		"user":    user,
+		"logs":    result.AuditLogs.Result,
+		"orgSlug": orgSlug,
+		"listId":  listID,
+	}
+	if result.AuditLogs.PageInfo.HasPrevPage {
+		gmap["prevURL"] = GetPaginationParams("prev", "", "", result.AuditLogs.PageInfo.Cursor)
+	}
+
+	if result.AuditLogs.PageInfo.HasNextPage {
+		gmap["nextURL"] = GetPaginationParams("next", "", "", result.AuditLogs.PageInfo.Cursor)
+	}
+
+	return gmap, nil
+}
diff --git a/list/routes.go b/list/routes.go
index 6af5cc6..09af3f9 100644
--- a/list/routes.go
+++ b/list/routes.go
@@ -39,6 +39,7 @@ func (s *Service) RegisterRoutes() {
 	s.Group.GET("/:id/delete", s.ListingDelete).Name = s.RouteName("listing_delete")
 	s.Group.POST("/:id/delete", s.ListingDelete).Name = s.RouteName("listing_delete")
 	s.Group.GET("/:id/links", s.ListingLinksManage).Name = s.RouteName("listing_links")
+	s.Group.GET("/:id/log", s.ListingLog).Name = s.RouteName("listing_log")
 	s.Group.GET("/:id/links/add", s.ListingLinksCreate).Name = s.RouteName("listing_link_create")
 	s.Group.POST("/:id/links/add", s.ListingLinksCreate).Name = s.RouteName("listing_link_create_post")
 	s.Group.GET("/:id/links/:lid/edit", s.ListingLinksUpdate).Name = s.RouteName("listing_link_update")
@@ -51,6 +52,33 @@ func (s *Service) RegisterRoutes() {
 	s.Group.GET("", s.ListingList).Name = s.RouteName("listing_list")
 }
 
+func (s *Service) ListingLog(c echo.Context) error {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		return echo.NotFoundHandler(c)
+	}
+	slug := c.Param("slug")
+
+	gctx := c.(*server.Context)
+	user := gctx.User.(*models.User)
+	userID := int(user.ID)
+
+	org, err := user.GetOrgsSlug(c.Request().Context(), models.OrgUserPermissionAdminWrite, slug)
+	if err != nil {
+		return err
+	}
+	if org != nil {
+		userID = 0 // Admin privileges can view all audit logs for org
+	}
+
+	gmap, err := links.FetchAuditLogs(c, userID, slug, id, 0)
+	if err != nil {
+		return err
+	}
+
+	return s.Render(c, http.StatusOK, "auditlog.html", gmap)
+}
+
 func (s *Service) ListingLinksUpdate(c echo.Context) error {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
@@ -1009,6 +1037,7 @@ func (s *Service) ListingList(c echo.Context) error {
 	pd.Data["apply"] = lt.Translate("Apply")
 	pd.Data["clear"] = lt.Translate("Clear")
 	pd.Data["is_default"] = lt.Translate("Is Default")
+	pd.Data["logs"] = lt.Translate("View Audit Logs")
 
 	type GraphQLResponse struct {
 		Listings struct {
diff --git a/migrations/0006_update_auditlog_metadata.down.sql b/migrations/0006_update_auditlog_metadata.down.sql
new file mode 100644
index 0000000..281a6d2
--- /dev/null
+++ b/migrations/0006_update_auditlog_metadata.down.sql
@@ -0,0 +1,2 @@
+UPDATE audit_log SET metadata = metadata - 'org_slug' WHERE metadata ? 'org_slug';
+UPDATE audit_log SET metadata = metadata - 'list_slug' WHERE metadata ? 'list_slug';
diff --git a/migrations/0006_update_auditlog_metadata.up.sql b/migrations/0006_update_auditlog_metadata.up.sql
new file mode 100644
index 0000000..66173cc
--- /dev/null
+++ b/migrations/0006_update_auditlog_metadata.up.sql
@@ -0,0 +1,10 @@
+UPDATE audit_log a
+SET metadata = jsonb_set(metadata, '{org_slug}', to_jsonb(o.slug))
+FROM organizations o
+WHERE (metadata->>'org_id')::INTEGER = o.id;
+
+UPDATE audit_log a
+SET metadata = jsonb_set(a.metadata, '{list_slug}', to_jsonb(l.slug))
+FROM listings l
+WHERE (a.metadata->>'list_id')::INTEGER = l.id;
+
diff --git a/templates/auditlog.html b/templates/auditlog.html
new file mode 100644
index 0000000..62220bc
--- /dev/null
+++ b/templates/auditlog.html
@@ -0,0 +1,47 @@
+{{template "base" .}}
+{{ define "title" }}{{ if gt .listId 0 }}{{ .pd.Data.listing }} {{ else if ne .orgSlug "" }}{{ .pd.Data.org }} {{ .orgSlug }} {{ end }}{{ .pd.Title }}{{ end }}
+<section class="app-header">
+  <h1 class="app-header__title">{{ if gt .listId 0 }}{{ .pd.Data.listing }} {{ else if ne .orgSlug "" }}{{ .pd.Data.org }} {{ .orgSlug }} {{ end }}{{ .pd.Title }}</h1>
+</section>
+
+<section class="card shadow-card">
+  <table class="striped mt-1">
+    <thead>
+      <tr>
+        <th class="text-center">{{.pd.Data.timestamp}}</th>
+        <th class="text-center">{{.pd.Data.org}}</th>
+        <th class="text-center">{{.pd.Data.event_type}}</th>
+        <th class="text-center">{{.pd.Data.ip_address}}</th>
+        <th class="text-center">{{.pd.Data.details}}</th>
+      </tr>
+    </thead>
+    <tbody>
+      {{ if .logs }}
+          {{range .logs}}
+          <tr>
+              <td class="text-center">{{ formatDate .CreatedOn }}</td>
+              <td class="text-center">{{ .Metadata.org_slug }}</td>
+              <td class="text-center">{{ .EventType }}</td>
+              <td class="text-center">{{ .IPAddress }}</td>
+              <td class="text-center">{{ .Details }}</td>
+          </tr>
+          {{end}}
+      {{else}}
+        <tr>
+            <td colspan="6"><p class="text-center">{{.pd.Data.no_logs}}</p></td>
+        </tr>
+      {{end}}
+    </tbody>
+  </table>
+  {{if or .prevURL .nextURL}}
+  <footer class="is-right">
+    {{if .prevURL}}
+    <a href="?{{.prevURL}}" class="button secondary">{{.pd.Data.prev}}</a>
+    {{end}}
+    {{if .nextURL}}
+    <a href="?{{.nextURL}}" class="button secondary">{{.pd.Data.next}}</a>
+    {{end}}
+  </footer>
+  {{end}}
+</section>
+{{template "base_footer" .}}
diff --git a/templates/listing_list.html b/templates/listing_list.html
index da23de4..838e598 100644
--- a/templates/listing_list.html
+++ b/templates/listing_list.html
@@ -86,6 +86,7 @@
                               <p><a class="mr-1" href="{{reverse "list:listing_update" $.org.Slug .ID}}">{{$.pd.Data.edit}}</a></p>
                               <p><a class="mr-1" href="{{reverse "list:listing_delete" $.org.Slug .ID}}">{{$.pd.Data.delete}}</a></p>
                               <p><a class="mr-1" href="{{reverse "analytics:detail" $.org.Slug "lists" .ID}}">{{$.pd.Data.analytics}}</a></p>
+                              <p><a class="mr-1" href="{{reverse "list:listing_log" $.org.Slug .ID}}">{{$.pd.Data.logs}}</a></p>
                               {{end}}
                           </div>
                       </details>
diff --git a/templates/org_list.html b/templates/org_list.html
index df27a2e..227d14f 100644
--- a/templates/org_list.html
+++ b/templates/org_list.html
@@ -58,6 +58,7 @@
                             <p><a class="mr-1" href="{{reverse "core:domain_list" .Slug}}">{{$.pd.Data.domains}}</a></p>
                             <p><a class="mr-1" href="{{reverse "core:integrations" .Slug}}">{{$.pd.Data.integrations}}</a></p>
                             <p><a class="mr-1" href="{{reverse "core:org_member_list" .Slug}}">{{$.pd.Data.members}}</a></p>
+                            <p><a class="mr-1" href="{{reverse "core:org_log" .Slug}}">{{$.pd.Data.logs}}</a></p>
                         {{end}}
                     </div>
                   </details>
diff --git a/templates/settings.html b/templates/settings.html
index b7e3598..43b0cb5 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -16,6 +16,7 @@
                             <p><a class="mr-1" href="{{reverse "accounts:profile_edit"}}">{{.pd.Data.edit_profile}}</a></p>
                             <p><a class="mr-1" href="{{reverse "accounts:update_email"}}">{{.pd.Data.update_email}}</a></p>
                             <p><a class="mr-1" href="{{reverse "accounts:change_password"}}">{{.pd.Data.change_password}}</a></p>
+                            <p><a class="mr-1" href="{{reverse "accounts:settings_log"}}">{{.pd.Data.logs}}</a></p>
                         </div>
                     </details>
                 </td>
-- 
2.47.2