Add filters and pagination to login attempts
This commit is contained in:
parent
b4ccdeff2f
commit
92c0cb4de0
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,25 +12,53 @@
|
||||||
|
|
||||||
{{ 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>
|
||||||
<table>
|
{{ if .Attempts -}}
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">{{( pgettext "Date" "header" )}}</th>
|
|
||||||
<th scope="col">{{( pgettext "Email" "header" )}}</th>
|
|
||||||
<th scope="col">{{( pgettext "IP Address" "header" )}}</th>
|
|
||||||
<th scope="col">{{( pgettext "Success" "header" )}}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{ range . -}}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ .Date }}</td>
|
<th scope="col">{{( pgettext "Date" "header" )}}</th>
|
||||||
<td>{{ .UserName }}</td>
|
<th scope="col">{{( pgettext "Email" "header" )}}</th>
|
||||||
<td>{{ .IPAddress }}</td>
|
<th scope="col">{{( pgettext "IP Address" "header" )}}</th>
|
||||||
<td>{{ if .Success }}{{( gettext "Yes" )}}{{ else }}{{( gettext "No" )}}{{ end }}</td>
|
<th scope="col">{{( pgettext "Success" "header" )}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
{{- end }}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{{ template "results.gohtml" . }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{- else -}}
|
||||||
|
<p>{{( gettext "No logging attempts found." )}}</p>
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
|
@ -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 }}
|
Loading…
Reference in New Issue