From 377f5bfabe3e821cf673d919c8619b69476ea279 Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Fri, 6 Nov 2020 12:39:41 -0500 Subject: [PATCH] Use scopes for filtering, update CSS --- cmd/server/assets/admin/mobileapps/index.html | 81 +++++++++ cmd/server/assets/admin/mobileapps/show.html | 85 --------- cmd/server/assets/admin/realms/edit.html | 40 ++--- cmd/server/assets/admin/realms/index.html | 78 ++++----- cmd/server/assets/admin/users/index.html | 163 ++++++++---------- cmd/server/assets/admin/users/new.html | 4 +- internal/routes/server.go | 8 +- pkg/controller/admin/admin.go | 4 + pkg/controller/admin/mobile_apps.go | 2 +- pkg/controller/admin/realms.go | 26 ++- pkg/controller/admin/users_list.go | 23 ++- pkg/controller/admin/users_operations.go | 2 +- pkg/database/scopes.go | 54 ++++++ pkg/database/user.go | 13 +- pkg/render/render.go | 8 + 15 files changed, 324 insertions(+), 267 deletions(-) create mode 100644 cmd/server/assets/admin/mobileapps/index.html delete mode 100644 cmd/server/assets/admin/mobileapps/show.html create mode 100644 pkg/database/scopes.go diff --git a/cmd/server/assets/admin/mobileapps/index.html b/cmd/server/assets/admin/mobileapps/index.html new file mode 100644 index 000000000..a172c2e1a --- /dev/null +++ b/cmd/server/assets/admin/mobileapps/index.html @@ -0,0 +1,81 @@ +{{define "admin/mobileapps/index"}} + +{{$apps := .apps}} + + + + + {{template "head" .}} + + + + {{template "admin/navbar" .}} + +
+ {{template "flash" .}} + +
+
+ + Mobile apps +
+ +
+
+
+ +
+ +
+
+ +
+
+
+ + {{if .apps}} + + + + + + + + + + {{range .apps}} + + + + + + + + {{end}} + +
Mobile appRealm
+ {{if .MobileApp.DeletedAt}} + + {{else}} + + {{end}} + + {{.MobileApp.Name}} + + {{.Realm.Name}} +
+ {{else}} +

+ There are no mobile apps. +

+ {{end}} +
+ + {{template "shared/pagination" .}} +
+ + +{{end}} diff --git a/cmd/server/assets/admin/mobileapps/show.html b/cmd/server/assets/admin/mobileapps/show.html deleted file mode 100644 index 943173dc7..000000000 --- a/cmd/server/assets/admin/mobileapps/show.html +++ /dev/null @@ -1,85 +0,0 @@ -{{define "admin/mobileapps/show"}} - -{{$apps := .apps}} - - - - - {{template "head" .}} - - - - {{template "admin/navbar" .}} - -
- {{template "flash" .}} - -
-
- - Mobile apps -
- -
-
-
- -
- -
-
- -
-
-
- -
- - {{if .apps}} -
- - - - - - - - - - {{range .apps}} - - - - - - - - - {{end}} - -
Mobile appRealmEnabled
- {{.MobileApp.Name}} - - {{.Realm.Name}} - - {{if .MobileApp.DeletedAt}} - Deleted - {{else}} - Enabled - {{end}} -
-
- {{else}} -

- There are no mobile apps. -

- {{end}} -
-
- - {{template "shared/pagination" .}} -
- - -{{end}} diff --git a/cmd/server/assets/admin/realms/edit.html b/cmd/server/assets/admin/realms/edit.html index c8798d4f5..7928c0ef6 100644 --- a/cmd/server/assets/admin/realms/edit.html +++ b/cmd/server/assets/admin/realms/edit.html @@ -24,16 +24,11 @@

Edit {{$realm.Name}}

Details
-
- Only a subset of realm fields are editable after creation. Work with - the realm administrator to update these values. -
-
{{ .csrfField }} -
Name
+
Name
@@ -45,7 +40,8 @@
Name
{{if .supportsPerRealmSigning}} -
Certificate
+
+
Certificate
{{end}} -
Abuse prevention
-
- {{if $realm.AbusePreventionEnabled}} -
Enabled
- {{else}} -
Disabled
- {{end}} -
+
+
Abuse prevention
+ {{if $realm.AbusePreventionEnabled}} +
Enabled
+
Calculated limit: {{.quotaLimit}}
+
Remaining: {{.quotaRemaining}}
+ {{else}} +
Disabled
+ {{end}} -
Mobile apps
- +
+
Mobile apps
+ View mobile apps → +
diff --git a/cmd/server/assets/admin/realms/index.html b/cmd/server/assets/admin/realms/index.html index 0d48c97ac..9a99694f0 100644 --- a/cmd/server/assets/admin/realms/index.html +++ b/cmd/server/assets/admin/realms/index.html @@ -15,48 +15,44 @@ {{template "flash" .}}
-
Realms
-
- - - {{if $realms}} -
- - - - - - - - - - - {{range $realms}} - - - - - - - {{end}} - -
IDNameRegion codeSigning key
{{.ID}} - {{.Name}} - {{.RegionCode}} - {{if .UseRealmCertificateKey}}Realm{{else}}System{{end}} -
-
- {{else}} -

- There are no realms. -

- {{end}} +
+ + Realms + + +
+ + {{if $realms}} + + + + + + + + + + + {{range $realms}} + + + + + + + {{end}} + +
IDNameRegionSigning key
{{.ID}} + {{.Name}} + {{.RegionCode}} + {{if .UseRealmCertificateKey}}Realm{{else}}System{{end}} +
+ {{else}} +

+ There are no users{{if .query}} that match the query{{end}}. +

+ {{end}}
diff --git a/cmd/server/assets/admin/users/index.html b/cmd/server/assets/admin/users/index.html index 945644faa..28f988d72 100644 --- a/cmd/server/assets/admin/users/index.html +++ b/cmd/server/assets/admin/users/index.html @@ -16,107 +16,94 @@ {{template "flash" .}}
-
Users
+
+ + Users + + + +
+
-
- -
+
-
- - -
-
- - -
-
- -
- - - - - - - - - - {{range $users}} - - - - - - {{end}} - -
NameEmail
- {{.Name}} - {{if .SystemAdmin}} - System admin - {{end}} - {{if .IsRealmAdmin}} - Realm admin - {{end}} - {{.Email}} - {{- /* cannot delete yourself */ -}} - {{if not (eq .ID $currentUser.ID)}} - {{if .SystemAdmin}} - - - - {{else}} - - - - {{end}} - {{end}} -
-
-
+ + {{if $users}} + + + + + + + + + + + {{range $users}} + + + + + + + {{end}} + +
NameEmail
+ {{if .SystemAdmin}} + + {{end}} + + {{.Name}} + {{if .IsRealmAdmin}} + + {{end}} + {{.Email}} + {{- /* cannot delete yourself */ -}} + {{if not (eq .ID $currentUser.ID)}} + {{if .SystemAdmin}} + + + + {{else}} + + + + {{end}} + {{end}} +
+ {{else}} +

+ There are no users{{if .query}} that match the query{{end}}. +

+ {{end}}
- {{template "shared/pagination" .}} + {{template "shared/pagination" .}} - - {{end}} diff --git a/cmd/server/assets/admin/users/new.html b/cmd/server/assets/admin/users/new.html index 677bce353..e474a8ac4 100644 --- a/cmd/server/assets/admin/users/new.html +++ b/cmd/server/assets/admin/users/new.html @@ -1,4 +1,4 @@ -{{define "admin/systemadmin/new"}} +{{define "admin/users/new"}} {{$user := .user}} @@ -23,7 +23,7 @@

New system admin

System admin details
-
+ {{ .csrfField }}
diff --git a/internal/routes/server.go b/internal/routes/server.go index 47d65b0b2..6cb343fb2 100644 --- a/internal/routes/server.go +++ b/internal/routes/server.go @@ -339,7 +339,7 @@ func Server( adminSub.Use(requireSystemAdmin) adminSub.Use(rateLimit) - adminController := admin.New(ctx, cfg, cacher, db, authProvider, h) + adminController := admin.New(ctx, cfg, cacher, db, authProvider, limiterStore, h) adminSub.Handle("", http.RedirectHandler("/admin/realms", http.StatusSeeOther)).Methods("GET") adminSub.Handle("/realms", adminController.HandleRealmsIndex()).Methods("GET") adminSub.Handle("/realms", adminController.HandleRealmsCreate()).Methods("POST") @@ -351,9 +351,9 @@ func Server( adminSub.Handle("/users", adminController.HandleUsersIndex()).Methods("GET") adminSub.Handle("/users/{id:[0-9]+}", adminController.HandleUserDelete()).Methods("DELETE") - adminSub.Handle("/users/systemadmin", adminController.HandleSystemAdminCreate()).Methods("POST") - adminSub.Handle("/users/systemadmin/new", adminController.HandleSystemAdminCreate()).Methods("GET") - adminSub.Handle("/users/systemadmin/{id:[0-9]+}", adminController.HandleSystemAdminRevoke()).Methods("DELETE") + adminSub.Handle("/users", adminController.HandleSystemAdminCreate()).Methods("POST") + adminSub.Handle("/users/new", adminController.HandleSystemAdminCreate()).Methods("GET") + adminSub.Handle("/users/{id:[0-9]+}/revoke", adminController.HandleSystemAdminRevoke()).Methods("DELETE") adminSub.Handle("/mobileapps", adminController.HandleMobileAppsShow()).Methods("GET") adminSub.Handle("/sms", adminController.HandleSMSUpdate()).Methods("GET", "POST") diff --git a/pkg/controller/admin/admin.go b/pkg/controller/admin/admin.go index 6e7a54d89..849700780 100644 --- a/pkg/controller/admin/admin.go +++ b/pkg/controller/admin/admin.go @@ -23,6 +23,7 @@ import ( "github.com/google/exposure-notifications-verification-server/pkg/config" "github.com/google/exposure-notifications-verification-server/pkg/database" "github.com/google/exposure-notifications-verification-server/pkg/render" + "github.com/sethvargo/go-limiter" "github.com/google/exposure-notifications-server/pkg/logging" @@ -35,6 +36,7 @@ type Controller struct { db *database.Database authProvider auth.Provider h *render.Renderer + limiter limiter.Store logger *zap.SugaredLogger } @@ -44,6 +46,7 @@ func New( cacher cache.Cacher, db *database.Database, authProvider auth.Provider, + limiter limiter.Store, h *render.Renderer, ) *Controller { logger := logging.FromContext(ctx).Named("admin") @@ -54,6 +57,7 @@ func New( db: db, authProvider: authProvider, h: h, + limiter: limiter, logger: logger, } } diff --git a/pkg/controller/admin/mobile_apps.go b/pkg/controller/admin/mobile_apps.go index 801603b01..4ec87f159 100644 --- a/pkg/controller/admin/mobile_apps.go +++ b/pkg/controller/admin/mobile_apps.go @@ -57,5 +57,5 @@ func (c *Controller) renderShowMobileApps(ctx context.Context, w http.ResponseWr m["apps"] = apps m["paginator"] = paginator m["query"] = q - c.h.RenderHTML(w, "admin/mobileapps/show", m) + c.h.RenderHTML(w, "admin/mobileapps/index", m) } diff --git a/pkg/controller/admin/realms.go b/pkg/controller/admin/realms.go index a9d0f7e21..1c8c87a6a 100644 --- a/pkg/controller/admin/realms.go +++ b/pkg/controller/admin/realms.go @@ -167,23 +167,38 @@ func (c *Controller) HandleRealmsUpdate() http.Handler { return } + var quotaLimit, quotaRemaining uint64 + if realm.AbusePreventionEnabled { + key, err := realm.QuotaKey(c.config.RateLimit.HMACKey) + if err != nil { + controller.InternalError(w, r, c.h, err) + return + } + + quotaLimit, quotaRemaining, err = c.limiter.Get(ctx, key) + if err != nil { + controller.InternalError(w, r, c.h, err) + return + } + } + // Requested form, stop processing. if r.Method == http.MethodGet { - c.renderEditRealm(ctx, w, realm, smsConfig) + c.renderEditRealm(ctx, w, realm, smsConfig, quotaLimit, quotaRemaining) return } var form FormData if err := controller.BindForm(w, r, &form); err != nil { flash.Error("Failed to process form: %v", err) - c.renderEditRealm(ctx, w, realm, smsConfig) + c.renderEditRealm(ctx, w, realm, smsConfig, quotaLimit, quotaRemaining) return } realm.CanUseSystemSMSConfig = form.CanUseSystemSMSConfig if err := c.db.SaveRealm(realm, currentUser); err != nil { flash.Error("Failed to create realm: %v", err) - c.renderEditRealm(ctx, w, realm, smsConfig) + c.renderEditRealm(ctx, w, realm, smsConfig, quotaLimit, quotaRemaining) return } @@ -192,11 +207,14 @@ func (c *Controller) HandleRealmsUpdate() http.Handler { }) } -func (c *Controller) renderEditRealm(ctx context.Context, w http.ResponseWriter, realm *database.Realm, smsConfig *database.SMSConfig) { +func (c *Controller) renderEditRealm(ctx context.Context, w http.ResponseWriter, + realm *database.Realm, smsConfig *database.SMSConfig, quotaLimit, quotaRemaining uint64) { m := controller.TemplateMapFromContext(ctx) m["realm"] = realm m["systemSMSConfig"] = smsConfig m["supportsPerRealmSigning"] = c.db.SupportsPerRealmSigning() + m["quotaLimit"] = quotaLimit + m["quotaRemaining"] = quotaRemaining c.h.RenderHTML(w, "admin/realms/edit", m) } diff --git a/pkg/controller/admin/users_list.go b/pkg/controller/admin/users_list.go index a86564543..80da2acb2 100644 --- a/pkg/controller/admin/users_list.go +++ b/pkg/controller/admin/users_list.go @@ -16,6 +16,7 @@ package admin import ( "net/http" + "strings" "github.com/google/exposure-notifications-verification-server/pkg/controller" "github.com/google/exposure-notifications-verification-server/pkg/database" @@ -23,11 +24,6 @@ import ( "github.com/gorilla/mux" ) -const ( - userType = "usertype" - systemAdmin = "sysadmin" -) - // HandleUsersIndex renders the list of all non-system-admin users. func (c *Controller) HandleUsersIndex() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -39,9 +35,20 @@ func (c *Controller) HandleUsersIndex() http.Handler { return } - typeFilter := r.FormValue(userType) + var scopes []database.Scope + filter := strings.TrimSpace(r.FormValue("filter")) + switch filter { + case "realmAdmins": + scopes = append(scopes, database.OnlyRealmAdmins()) + case "systemAdmins": + scopes = append(scopes, database.OnlySystemAdmins()) + default: + } + q := r.FormValue(QueryKeySearch) - users, paginator, err := c.db.ListUsers(pageParams, q, typeFilter == systemAdmin) + scopes = append(scopes, database.WithUserSearch(q)) + + users, paginator, err := c.db.ListUsers(pageParams, scopes...) if err != nil { controller.InternalError(w, r, c.h, err) return @@ -50,7 +57,7 @@ func (c *Controller) HandleUsersIndex() http.Handler { m := controller.TemplateMapFromContext(ctx) m["users"] = users m["query"] = q - m[userType] = typeFilter + m["filter"] = filter m["paginator"] = paginator c.h.RenderHTML(w, "admin/users/index", m) }) diff --git a/pkg/controller/admin/users_operations.go b/pkg/controller/admin/users_operations.go index 362f69062..6c1e0a544 100644 --- a/pkg/controller/admin/users_operations.go +++ b/pkg/controller/admin/users_operations.go @@ -109,7 +109,7 @@ func (c *Controller) HandleSystemAdminCreate() http.Handler { func (c *Controller) renderNewUser(ctx context.Context, w http.ResponseWriter, user *database.User) { m := controller.TemplateMapFromContext(ctx) m["user"] = user - c.h.RenderHTML(w, "admin/systemadmin/new", m) + c.h.RenderHTML(w, "admin/users/new", m) } // HandleSystemAdminRevoke removes admin from a system admin. diff --git a/pkg/database/scopes.go b/pkg/database/scopes.go new file mode 100644 index 000000000..9884a11b0 --- /dev/null +++ b/pkg/database/scopes.go @@ -0,0 +1,54 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "github.com/google/exposure-notifications-verification-server/internal/project" + "github.com/jinzhu/gorm" +) + +// Scope is a type alias to a gorm scope. It exists to reduce duplicate and +// function length. Note this is an ALIAS. It is NOT a new type. +type Scope = func(db *gorm.DB) *gorm.DB + +// OnlySystemAdmins returns a scope that restricts the query to system admins. +// It's only applicable to functions that query User. +func OnlySystemAdmins() Scope { + return func(db *gorm.DB) *gorm.DB { + return db.Where(&User{SystemAdmin: true}) + } +} + +// OnlyRealmAdmins returns a scope that restricts the query to users that are +// administrators of 1 or more realms. It's only applicable to functions that +// query User. +func OnlyRealmAdmins() Scope { + return func(db *gorm.DB) *gorm.DB { + return db.Joins("INNER JOIN (SELECT DISTINCT user_id FROM admin_realms) ar ON users.id = ar.user_id") + } +} + +// WithUserSearch returns a scope that adds querying for users by email and +// name, case-insensitive. It's only applicable to functions that query User. +func WithUserSearch(q string) Scope { + return func(db *gorm.DB) *gorm.DB { + q = project.TrimSpace(q) + if q != "" { + q = `%` + q + `%` + return db.Where("users.email ILIKE ? OR users.name ILIKE ?", q, q) + } + return db + } +} diff --git a/pkg/database/user.go b/pkg/database/user.go index cd0c3500c..a3bef5b4a 100644 --- a/pkg/database/user.go +++ b/pkg/database/user.go @@ -231,21 +231,12 @@ func (u *User) Stats(db *Database, realmID uint, start, stop time.Time) ([]*User // ListUsers returns a list of all users sorted by name. // Warning: This list may be large. Use Realm.ListUsers() to get users scoped to a realm. -func (db *Database) ListUsers(p *pagination.PageParams, q string, systemAdminOnly bool) ([]*User, *pagination.Paginator, error) { +func (db *Database) ListUsers(p *pagination.PageParams, scopes ...Scope) ([]*User, *pagination.Paginator, error) { var users []*User query := db.db.Model(&User{}). + Scopes(scopes...). Order("LOWER(name) ASC") - if systemAdminOnly { - query = query.Where("system_admin IS TRUE") - } - - q = project.TrimSpace(q) - if q != "" { - q = `%` + q + `%` - query = query.Where("(users.email ILIKE ? OR users.name ILIKE ?)", q, q) - } - if p == nil { p = new(pagination.PageParams) } diff --git a/pkg/render/render.go b/pkg/render/render.go index 4c485e973..6b2ff3364 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -143,6 +143,13 @@ func safeHTML(s string) htmltemplate.HTML { return htmltemplate.HTML(s) } +func selectedIf(v bool) htmltemplate.HTML { + if v { + return htmltemplate.HTML("selected") + } + return "" +} + func templateFuncs() htmltemplate.FuncMap { return map[string]interface{}{ "joinStrings": strings.Join, @@ -151,6 +158,7 @@ func templateFuncs() htmltemplate.FuncMap { "toLower": strings.ToLower, "toUpper": strings.ToUpper, "safeHTML": safeHTML, + "selectedIf": selectedIf, } }