~netlandish/links

47a5324fd99b0b03effed7dc13032ce9ab5b2814 — Peter Sanchez a month ago 9ac75f7
Adding audit log views for the following:

- Personal account level
- Organization level
- Link listing level

Changelog-added: Handlers to view audit logs at various levels.
M accounts/routes.go => accounts/routes.go +14 -0
@@ 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

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +51 -6
@@ 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
	}


M cmd/migrations.go => cmd/migrations.go +7 -0
@@ 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,
		),
	}
}

M core/import.go => core/import.go +2 -0
@@ 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),

M core/routes.go => core/routes.go +24 -0
@@ 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

M helpers.go => helpers.go +103 -0
@@ 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
}

M list/routes.go => list/routes.go +29 -0
@@ 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 {

A migrations/0006_update_auditlog_metadata.down.sql => migrations/0006_update_auditlog_metadata.down.sql +2 -0
@@ 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';

A migrations/0006_update_auditlog_metadata.up.sql => migrations/0006_update_auditlog_metadata.up.sql +10 -0
@@ 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;


A templates/auditlog.html => templates/auditlog.html +47 -0
@@ 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" .}}

M templates/listing_list.html => templates/listing_list.html +1 -0
@@ 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>

M templates/org_list.html => templates/org_list.html +1 -0
@@ 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>

M templates/settings.html => templates/settings.html +1 -0
@@ 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>

Do not follow this link