Add filters and pagination to login attempts

This commit is contained in:
jordi fita mas 2024-05-03 20:45:14 +02:00
parent b4ccdeff2f
commit 92c0cb4de0
4 changed files with 189 additions and 32 deletions

98
pkg/user/filter.go Normal file
View File

@ -0,0 +1,98 @@
package user
import (
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
)
type filterForm struct {
company *auth.Company
FromDate *form.Input
ToDate *form.Input
Cursor *form.Cursor
}
func newFilterForm() *filterForm {
return &filterForm{
FromDate: &form.Input{
Name: "from_date",
},
ToDate: &form.Input{
Name: "to_date",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 5,
},
}
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.FromDate.FillValue(r)
f.ToDate.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
maybeAppendWhere("attempted_at >= $%d", f.FromDate.Val, nil)
maybeAppendWhere("attempted_at <= $%d", f.ToDate.Val, nil)
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(attempted_at, attempt_id) < ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
if len(where) == 0 {
return "1=1", args
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(customers []*loginAttemptEntry) []*loginAttemptEntry {
return form.BuildCursor(f.Cursor, customers, func(entry *loginAttemptEntry) []string {
return []string{entry.Date.Format(database.ISODateTimeFormat), strconv.Itoa(entry.ID)}
})
}
func (f *filterForm) HasValue() bool {
return f.FromDate.Val != "" ||
f.ToDate.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

View File

@ -2,6 +2,8 @@ package user
import ( import (
"context" "context"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"fmt"
"net/http" "net/http"
"time" "time"
@ -11,32 +13,44 @@ import (
) )
func serveLoginAttemptIndex(w http.ResponseWriter, r *http.Request, loginAttempt *auth.User, company *auth.Company, conn *database.Conn) { func serveLoginAttemptIndex(w http.ResponseWriter, r *http.Request, loginAttempt *auth.User, company *auth.Company, conn *database.Conn) {
loginAttempts, err := collectLoginAttemptEntries(r.Context(), conn) filters := newFilterForm()
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
loginAttempts, err := collectLoginAttemptEntries(r.Context(), conn, filters)
if err != nil { if err != nil {
panic(err) panic(err)
} }
loginAttempts.MustRender(w, r, loginAttempt, company) index := &loginAttemptIndex{
Attempts: filters.buildCursor(loginAttempts),
Filters: filters,
}
index.MustRender(w, r, loginAttempt, company)
} }
func collectLoginAttemptEntries(ctx context.Context, conn *database.Conn) (loginAttemptIndex, error) { func collectLoginAttemptEntries(ctx context.Context, conn *database.Conn, filters *filterForm) ([]*loginAttemptEntry, error) {
rows, err := conn.Query(ctx, ` where, args := filters.BuildQuery(nil)
select user_name rows, err := conn.Query(ctx, fmt.Sprintf(`
select attempt_id
, user_name
, host(ip_address) , host(ip_address)
, attempted_at , attempted_at
, success , success
from company_login_attempt from company_login_attempt
order by attempted_at desc where (%s)
limit 500 order by attempted_at desc, attempt_id desc
`) limit %d
`, where, filters.PerPage()+1), args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var entries loginAttemptIndex var entries []*loginAttemptEntry
for rows.Next() { for rows.Next() {
entry := &loginAttemptEntry{} entry := &loginAttemptEntry{}
if err = rows.Scan(&entry.UserName, &entry.IPAddress, &entry.Date, &entry.Success); err != nil { if err = rows.Scan(&entry.ID, &entry.UserName, &entry.IPAddress, &entry.Date, &entry.Success); err != nil {
return nil, err return nil, err
} }
entries = append(entries, entry) entries = append(entries, entry)
@ -46,14 +60,22 @@ func collectLoginAttemptEntries(ctx context.Context, conn *database.Conn) (login
} }
type loginAttemptEntry struct { type loginAttemptEntry struct {
ID int
UserName string UserName string
IPAddress string IPAddress string
Date time.Time Date time.Time
Success bool Success bool
} }
type loginAttemptIndex []*loginAttemptEntry type loginAttemptIndex struct {
Attempts []*loginAttemptEntry
func (page *loginAttemptIndex) MustRender(w http.ResponseWriter, r *http.Request, loginAttempt *auth.User, company *auth.Company) { Filters *filterForm
template.MustRenderAdmin(w, r, loginAttempt, company, "user/login-attempts.gohtml", page) }
func (page *loginAttemptIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "user/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "user/login-attempts.gohtml", "user/results.gohtml")
}
} }

View File

@ -12,7 +12,39 @@
{{ define "content" -}} {{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/user.loginAttemptIndex*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/user.loginAttemptIndex*/ -}}
{{ template "filters-toggle" }}
<form class="filters" method="GET" action="/admin/users/login-attempts"
data-hx-target="main" data-hx-boost="true" data-hx-trigger="change,search,submit"
aria-labelledby="filters-toggle"
>
{{ with .Filters }}
<fieldset>
{{ with .FromDate -}}
<label>
{{( pgettext "From date" "input" )}}<br>
<input type="date"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .ToDate -}}
<label>
{{( pgettext "To date" "input" )}}<br>
<input type="date"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
{{ template "error-message" . }}
</label>
{{- end }}
</fieldset>
{{ end }}
{{ if .Filters.HasValue }}
<a href="/admin/users/login-attempts" class="button">{{( pgettext "Reset" "action" )}}</a>
{{ end }}
</form>
<h2>{{( pgettext "Login Attempts" "title" )}}</h2> <h2>{{( pgettext "Login Attempts" "title" )}}</h2>
{{ if .Attempts -}}
<table> <table>
<thead> <thead>
<tr> <tr>
@ -23,14 +55,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{ range . -}} {{ template "results.gohtml" . }}
<tr>
<td>{{ .Date }}</td>
<td>{{ .UserName }}</td>
<td>{{ .IPAddress }}</td>
<td>{{ if .Success }}{{( gettext "Yes" )}}{{ else }}{{( gettext "No" )}}{{ end }}</td>
</tr>
{{- end }}
</tbody> </tbody>
</table> </table>
{{- else -}}
<p>{{( gettext "No logging attempts found." )}}</p>
{{- end }}
{{- end }} {{- end }}

View File

@ -0,0 +1,9 @@
{{ range .Attempts -}}
<tr>
<td>{{ .Date }}</td>
<td>{{ .UserName }}</td>
<td>{{ .IPAddress }}</td>
<td>{{ if .Success }}{{( gettext "Yes" )}}{{ else }}{{( gettext "No" )}}{{ end }}</td>
</tr>
{{- end }}
{{ template "pagination" .Filters.Cursor | colspan 4 }}