Admin Actions

* Add impersonate feature
* Add ban/unban user feature
* Add promote/demote admin status feature
* Add admin user deletion feature
* Admin ability to see other status certification pics
* Nav bar indicator of pending admin actions such as cert pics
  needing approval
* Admin ability to search cert pics for specific user
forums
Noah 2022-08-14 16:27:57 -07:00
parent 0caf12eb00
commit dbca37977e
16 changed files with 569 additions and 13 deletions

View File

@ -56,6 +56,13 @@ func Login() http.HandlerFunc {
return
}
// Is their account banned or disabled?
if user.Status != models.UserStatusActive {
session.FlashError(w, r, "Your account has been %s. If you believe this was done in error, please contact support.", user.Status)
templates.Redirect(w, r.URL.Path)
return
}
// OK. Log in the user's session.
session.LoginUser(w, r, user)

View File

@ -37,6 +37,12 @@ func Profile() http.HandlerFunc {
return
}
// Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
vars := map[string]interface{}{
"User": user,
"IsFriend": models.FriendStatus(currentUser.ID, user.ID),

View File

@ -0,0 +1,126 @@
package admin
import (
"net/http"
"strconv"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/models/deletion"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
// Admin actions against a user account.
func UserActions() http.HandlerFunc {
tmpl := templates.Must("admin/user_actions.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
intent = r.FormValue("intent")
confirm = r.Method == http.MethodPost
userId uint64
)
// Get current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Failed to get current user: %s", err)
templates.Redirect(w, "/")
return
}
if idInt, err := strconv.Atoi(r.FormValue("user_id")); err == nil {
userId = uint64(idInt)
} else {
session.FlashError(w, r, "Invalid or missing user_id parameter: %s", err)
templates.Redirect(w, "/admin")
return
}
// Get this user.
user, err := models.GetUser(userId)
if err != nil {
session.FlashError(w, r, "Didn't find user ID in database: %s", err)
templates.Redirect(w, "/admin")
return
}
switch intent {
case "impersonate":
if confirm {
if err := session.ImpersonateUser(w, r, user, currentUser); err != nil {
session.FlashError(w, r, "Failed to impersonate user: %s", err)
} else {
session.Flash(w, r, "You are now impersonating %s", user.Username)
templates.Redirect(w, "/me")
return
}
}
case "ban":
if confirm {
status := r.PostFormValue("status")
if status == "active" {
user.Status = models.UserStatusActive
} else if status == "banned" {
user.Status = models.UserStatusBanned
}
user.Save()
session.Flash(w, r, "User ban status updated!")
templates.Redirect(w, "/u/"+user.Username)
return
}
case "promote":
if confirm {
action := r.PostFormValue("action")
user.IsAdmin = action == "promote"
user.Save()
session.Flash(w, r, "User admin status updated!")
templates.Redirect(w, "/u/"+user.Username)
return
}
case "delete":
if confirm {
if err := deletion.DeleteUser(user); err != nil {
session.FlashError(w, r, "Failed when deleting the user: %s", err)
} else {
session.Flash(w, r, "User has been deleted!")
}
templates.Redirect(w, "/admin")
return
}
default:
session.FlashError(w, r, "Unsupported admin user intent: %s", intent)
templates.Redirect(w, "/admin")
return
}
var vars = map[string]interface{}{
"Intent": intent,
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Un-impersonate a user account.
func Unimpersonate() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess := session.Get(r)
if sess.Impersonator > 0 {
user, err := models.GetUser(sess.Impersonator)
if err != nil {
session.FlashError(w, r, "Couldn't unimpersonate: impersonator (%d) is not an admin!", user.ID)
templates.Redirect(w, "/")
return
}
session.LoginUser(w, r, user)
session.Flash(w, r, "No longer impersonating.")
templates.Redirect(w, "/")
}
templates.Redirect(w, "/")
})
}

View File

@ -167,6 +167,40 @@ func Certification() http.HandlerFunc {
func AdminCertification() http.HandlerFunc {
tmpl := templates.Must("admin/certification.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// View status
var view = r.FormValue("view")
if view == "" {
view = "pending"
}
// Short circuit the GET view for username/email search (exact match)
if username := r.FormValue("username"); username != "" {
user, err := models.FindUser(username)
if err != nil {
session.FlashError(w, r, "Username or email '%s' not found.", username)
templates.Redirect(w, r.URL.Path)
return
}
cert, err := models.GetCertificationPhoto(user.ID)
if err != nil {
session.FlashError(w, r, "Couldn't get their certification photo: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
var vars = map[string]interface{}{
"View": view,
"Photos": []*models.CertificationPhoto{cert},
"UserMap": &models.UserMap{user.ID: user},
"FoundUser": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Making a verdict?
if r.Method == http.MethodPost {
var (
@ -275,7 +309,7 @@ func AdminCertification() http.HandlerFunc {
Sort: "updated_at desc",
}
pager.ParsePage(r)
photos, err := models.CertificationPhotosNeedingApproval(models.CertificationPhotoPending, pager)
photos, err := models.CertificationPhotosNeedingApproval(models.CertificationPhotoStatus(view), pager)
if err != nil {
session.FlashError(w, r, "Couldn't load certification photos from DB: %s", err)
}
@ -291,6 +325,7 @@ func AdminCertification() http.HandlerFunc {
}
var vars = map[string]interface{}{
"View": view,
"Photos": photos,
"UserMap": userMap,
"Pager": pager,

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/controller/photo"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
)
@ -24,6 +25,19 @@ func LoginRequired(handler http.Handler) http.Handler {
return
}
// Are they banned or disabled?
if user.Status == models.UserStatusDisabled {
session.LogoutUser(w, r)
session.FlashError(w, r, "Your account has been disabled and you are now logged out.")
templates.Redirect(w, "/")
return
} else if user.Status == models.UserStatusBanned {
session.LogoutUser(w, r)
session.FlashError(w, r, "Your account has been banned and you are now logged out.")
templates.Redirect(w, "/")
return
}
// Ping LastLoginAt for long lived sessions.
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown {
user.LastLoginAt = time.Now()

View File

@ -63,6 +63,13 @@ func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *
return p, result.Error
}
// CountCertificationPhotosNeedingApproval gets the count of pending photos for admin alert.
func CountCertificationPhotosNeedingApproval() int64 {
var count int64
DB.Where("status = ?", CertificationPhotoPending).Model(&CertificationPhoto{}).Count(&count)
return count
}
// Save photo.
func (p *CertificationPhoto) Save() error {
result := DB.Save(p)

View File

@ -47,6 +47,7 @@ type UserStatus string
const (
UserStatusActive = "active"
UserStatusDisabled = "disabled"
UserStatusBanned = "banned"
)
// CreateUser. It is assumed username and email are correctly formatted.

View File

@ -41,6 +41,7 @@ func New() http.Handler {
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))
mux.Handle("/friends", middleware.LoginRequired(friend.Friends()))
mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend()))
mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
// Certification Required. Pages that only full (verified) members can access.
mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
@ -49,6 +50,7 @@ func New() http.Handler {
// Admin endpoints.
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification()))
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions()))
// JSON API endpoints.
mux.HandleFunc("/v1/version", api.Version())

View File

@ -16,12 +16,13 @@ import (
// Session cookie object that is kept server side in Redis.
type Session struct {
UUID string `json:"-"` // not stored
LoggedIn bool `json:"loggedIn"`
UserID uint64 `json:"userId,omitempty"`
Flashes []string `json:"flashes,omitempty"`
Errors []string `json:"errors,omitempty"`
LastSeen time.Time `json:"lastSeen"`
UUID string `json:"-"` // not stored
LoggedIn bool `json:"loggedIn"`
UserID uint64 `json:"userId,omitempty"`
Flashes []string `json:"flashes,omitempty"`
Errors []string `json:"errors,omitempty"`
Impersonator uint64 `json:"impersonator,omitempty"`
LastSeen time.Time `json:"lastSeen"`
}
const (
@ -88,6 +89,7 @@ func (s *Session) Save(w http.ResponseWriter) {
Name: config.SessionCookieName,
Value: s.UUID,
MaxAge: config.SessionCookieMaxAge,
Path: "/",
HttpOnly: true,
}
http.SetCookie(w, cookie)
@ -144,6 +146,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
sess := Get(r)
sess.LoggedIn = true
sess.UserID = u.ID
sess.Impersonator = 0
sess.Save(w)
// Ping the user's last login time.
@ -151,6 +154,32 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
return u.Save()
}
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
func ImpersonateUser(w http.ResponseWriter, r *http.Request, u *models.User, impersonator *models.User) error {
if u == nil || u.ID == 0 {
return errors.New("not a valid user account")
}
if impersonator == nil || impersonator.ID == 0 || !impersonator.IsAdmin {
return errors.New("impersonator not a valid admin account")
}
sess := Get(r)
sess.LoggedIn = true
sess.UserID = u.ID
sess.Impersonator = impersonator.ID
sess.Save(w)
// Ping the user's last login time.
u.LastLoginAt = time.Now()
return u.Save()
}
// Impersonated returns if the current session has an impersonator.
func Impersonated(r *http.Request) bool {
sess := Get(r)
return sess.Impersonator > 0
}
// LogoutUser signs a user out.
func LogoutUser(w http.ResponseWriter, r *http.Request) {
sess := Get(r)

View File

@ -30,11 +30,16 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
m["CurrentUser"] = nil
m["NavUnreadMessages"] = 0
m["NavFriendRequests"] = 0
m["NavAdminNotifications"] = 0 // total count of admin notifications for nav
m["NavCertificationPhotos"] = 0 // admin indicator for certification photos
m["SessionImpersonated"] = false
if r == nil {
return
}
m["SessionImpersonated"] = session.Impersonated(r)
if user, err := session.CurrentUser(r); err == nil {
m["LoggedIn"] = true
m["CurrentUser"] = user
@ -52,5 +57,13 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
} else {
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
}
// Are we admin?
if user.IsAdmin {
// Any pending certification photos?
m["NavCertificationPhotos"] = models.CountCertificationPhotosNeedingApproval()
}
}
m["NavAdminNotifications"] = m["NavCertificationPhotos"]
}

View File

@ -111,6 +111,14 @@
Log out
</a>
</li>
{{if .SessionImpersonated}}
<li>
<a href="/admin/unimpersonate" class="has-text-danger">
<span class="icon"><i class="fa fa-ghost"></i></span>
<span>Unimpersonate</span>
</a>
</li>
{{end}}
<li>
<a href="/account/delete">
<span class="icon"><i class="fa fa-trash"></i></span>

View File

@ -34,6 +34,11 @@
{{.User.Username}}
{{end}}
</h1>
{{if ne .User.Status "active"}}
<h2 class="subtitle">
({{.User.Status}})
</h2>
{{end}}
</div>
<div class="column is-narrow">
@ -305,6 +310,47 @@
</table>
</div>
</div>
<!-- Admin Actions-->
{{if .CurrentUser.IsAdmin}}
<div class="card block">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<i class="fa fa-gavel pr-2"></i>
Admin Actions
</p>
</header>
<div class="card-content">
<ul class="menu-list">
<li>
<a href="/admin/user-action?intent=impersonate&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-ghost"></i></span>
<span>Impersonate this user</span>
</a>
</li>
<li>
<a href="/admin/user-action?intent=ban&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-ban"></i></span>
<span>Ban/unban this user</span>
</a>
</li>
<li>
<a href="/admin/user-action?intent=promote&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-gavel"></i></span>
<span>Add/Remove admin rights</span>
</a>
</li>
<li>
<a href="/admin/user-action?intent=delete&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-trash"></i></span>
<span>Delete user account</span>
</a>
</li>
</ul>
</div>
</div>
{{end}}
</div>
</div>
</div>

View File

@ -1,5 +1,6 @@
{{define "title"}}Admin - Certification Photos{{end}}
{{define "content"}}
{{$Root := .}}
<div class="container">
<section class="hero is-danger is-bold">
<div class="hero-body">
@ -12,11 +13,69 @@
</section>
<div class="block p-4">
<div class="block">
There {{Pluralize64 .Pager.Total "is" "are"}} <strong>{{.Pager.Total}}</strong> Certification Photo{{Pluralize64 .Pager.Total}} needing approval.
<div class="columns">
<div class="column">
{{if .Pager}}
There {{Pluralize64 .Pager.Total "is" "are"}} <strong>{{.Pager.Total}}</strong> Certification Photo{{Pluralize64 .Pager.Total}}
{{if eq .View "pending"}}
needing approval.
{{else}}
at status "{{.View}}."
{{end}}
{{else if .FoundUser}}
Found user <strong><a href="/u/{{.FoundUser.Username}}" class="has-text-dark">{{.FoundUser.Username}}</a></strong>
(<a href="mailto:{{.FoundUser.Email}}">{{.FoundUser.Email}}</a>)
{{end}}
</div>
<div class="column is-narrow">
<div class="tabs is-toggle">
<ul>
<li{{if eq .View "pending"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?view=pending">Needing Approval</a>
</li>
<li{{if eq .View "approved"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?view=approved">Approved</a>
</li>
<li{{if eq .View "rejected"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?view=rejected">Rejected</a>
</li>
</ul>
</div>
</div>
</div>
{{$Root := .}}
<div class="block">
<form method="GET" action="{{.Request.URL.Path}}">
<div class="field block">
<div class="label" for="username">Search username or email:</div>
<input type="text" class="input"
name="username"
id="username"
placeholder="Press Enter to search">
</div>
</form>
</div>
{{if .Pager}}
<nav class="pagination" role="navigation" aria-label="pagination">
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?view={{.View}}&page={{.Pager.Next}}">Next page</a>
<ul class="pagination-list">
{{range .Pager.Iter}}
<li>
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
aria-label="Page {{.Page}}"
href="{{$Root.Request.URL.Path}}?view={{$Root.View}}&page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
{{end}}
<div class="columns is-multiline">
{{range .Photos}}
<div class="column is-one-third">
@ -57,21 +116,45 @@
</div>
</div>
<div class="block">
<div class="columns">
<div class="column is-narrow">
<strong>Status:
</div>
<div class="column">
{{if eq .Status "pending"}}
<strong class="has-text-warning">Pending Approval</strong>
{{else if eq .Status "approved"}}
<strong class="has-text-success">Approved</strong>
{{else if eq .Status "rejected"}}
<strong class="has-text-danger">Rejected</strong>
{{else}}
<strong>{{.Status}}</strong>
{{end}}
</div>
</div>
</div>
<div class="field">
<textarea class="textarea" name="comment"
cols="60" rows="2"
placeholder="Admin comment (for rejection)"></textarea>
placeholder="Admin comment (for rejection)">{{.AdminComment}}</textarea>
</div>
</div>
<footer class="card-footer">
{{if not (eq .Status "rejected")}}
<button type="submit" name="verdict" value="reject" class="card-footer-item button is-danger">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Reject</span>
</button>
{{end}}
{{if not (eq .Status "approved")}}
<button type="submit" name="verdict" value="approve" class="card-footer-item button is-success">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Approve</span>
</button>
{{end}}
</footer>
</div>
</form>

View File

@ -26,6 +26,7 @@
<a href="/admin/photo/certification">
<span class="icon"><i class="fa fa-certificate"></i></span>
Certification Photos
{{if .NavCertificationPhotos}}<span class="tag is-danger ml-1">{{.NavCertificationPhotos}}</span>{{end}}
</a>
</li>
</ul>

View File

@ -0,0 +1,166 @@
{{define "title"}}Compose a Message{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Admin Action
</h1>
<h2 class="subtitle">On user {{.User.Username}}</h2>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
<div class="card" style="width: 100%; max-width: 640px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
{{if eq .Intent "impersonate"}}
<span class="icon"><i class="fa fa-ghost"></i></span>
Impersonate User
{{else if eq .Intent "ban"}}
<span class="icon"><i class="fa fa-ban"></i></span>
Ban User
{{else if eq .Intent "promote"}}
<span class="icon"><i class="fa fa-gavel"></i></span>
Promote User
{{else if eq .Intent "delete"}}
<span class="icon"><i class="fa fa-trash"></i></span>
Delete User
{{end}}
</p>
</header>
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
{{if .User.ProfilePhoto.ID}}
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{or .User.Name "(no name)"}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>
</p>
</div>
</div>
<form action="/admin/user-action" method="POST">
{{InputCSRF}}
<input type="hidden" name="intent" value="{{.Intent}}">
<input type="hidden" name="user_id" value="{{.User.ID}}">
{{if eq .Intent "impersonate"}}
<div class="block content">
<h3>With great power...</h3>
<p>
By <strong>impersonating</strong> this user, you will be considered as "logged in"
to their account and have access to their messages, profile, photos and settings.
</p>
<p>
Please respect user privacy and only impersonate an account as needed to diagnose
a customer support issue or similar.
</p>
</div>
<div class="field has-text-centered">
<button type="submit" class="button is-success">
Log in as {{.User.Username}}
</button>
</div>
{{else if eq .Intent "ban"}}
<div class="block content">
<p>
This user is currently:
{{if eq .User.Status "active"}}
<strong class="has-text-success">Active (not banned)</strong>
{{else if eq .User.Status "disabled"}}
<strong class="has-text-warning">Disabled</strong>
{{else if eq .User.Status "banned"}}
<strong class="has-text-danger">Banned</strong>
{{end}}
</p>
<p>
Select a new status for them below:
</p>
</div>
<div class="field has-text-centered">
<button type="submit" name="status" value="active" class="button is-success">
Active
</button>
<button type="submit" name="status" value="banned" class="button is-danger">
Banned
</button>
</div>
{{else if eq .Intent "promote"}}
<div class="block content">
<p>
This user is currently:
{{if .User.IsAdmin}}
<strong class="has-text-danger">Admin</strong>
{{else}}
<strong class="has-text-success">NOT Admin</strong>
{{end}}
</p>
<p>
Select a new status for them below:
</p>
</div>
<div class="field has-text-centered">
<button type="submit" name="action" value="promote" class="button is-success">
Make Admin
</button>
<button type="submit" name="action" value="demote" class="button is-danger">
Remove Admin
</button>
</div>
{{else if eq .Intent "delete"}}
<div class="block content">
<p>
Click the button below to <strong>deep delete</strong> this user account.
</p>
</div>
<div class="field has-text-centered">
<button type="submit" class="button is-danger">
Delete User Account
</button>
</div>
{{end}}
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName");
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
});
});
</script>
{{end}}

View File

@ -116,7 +116,10 @@
{{end}}
</figure>
</div>
<div class="column">{{.CurrentUser.Username}}</div>
<div class="column">
{{.CurrentUser.Username}}
{{if .NavAdminNotifications}}<span class="tag is-danger ml-1">{{.NavAdminNotifications}}</span>{{end}}
</div>
</div>
</a>
@ -127,7 +130,16 @@
<a class="navbar-item" href="/photo/upload">Upload Photo</a>
<a class="navbar-item" href="/settings">Settings</a>
{{if .CurrentUser.IsAdmin}}
<a class="navbar-item has-text-danger" href="/admin">Admin</a>
<a class="navbar-item has-text-danger" href="/admin">
Admin
{{if .NavAdminNotifications}}<span class="tag is-danger ml-1">{{.NavAdminNotifications}}</span>{{end}}
</a>
{{end}}
{{if .SessionImpersonated}}
<a href="/admin/unimpersonate" class="navbar-item has-text-danger">
<span class="icon"><i class="fa fa-ghost"></i></span>
<span>Unimpersonate</span>
</a>
{{end}}
<a class="navbar-item" href="/logout">Log out</a>
</div>