Admin Groups & Permissions
Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
This commit is contained in:
parent
01317a7ff8
commit
47aaf15078
4
Makefile
4
Makefile
|
@ -18,6 +18,10 @@ setup:
|
|||
build:
|
||||
go build $(LDFLAGS) -o nonshy cmd/nonshy/main.go
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
go run cmd/nonshy/main.go web --debug
|
66
pkg/config/admin_scopes.go
Normal file
66
pkg/config/admin_scopes.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package config
|
||||
|
||||
// All available admin scopes
|
||||
const (
|
||||
// Social moderation over the chat and forums.
|
||||
// - Chat: have operator controls in the chat room
|
||||
// - Forum: ability to edit and delete user posts
|
||||
// - Photo: omniscient view of all gallery photos, can edit/delete photos
|
||||
// - Inner circle: ability to remove users from it
|
||||
ScopeChatModerator = "social.moderator.chat"
|
||||
ScopeForumModerator = "social.moderator.forum"
|
||||
ScopePhotoModerator = "social.moderator.photo"
|
||||
ScopeCircleModerator = "social.moderator.inner-circle"
|
||||
|
||||
// Certification photo management
|
||||
// - Approve: ability to respond to pending certification pics
|
||||
// - List: paginate thru all approved or rejected photos
|
||||
// - View: inspect specific user photos
|
||||
ScopeCertificationApprove = "certification.approve"
|
||||
ScopeCertificationList = "certification.list"
|
||||
ScopeCertificationView = "certification.view"
|
||||
|
||||
// Website administration
|
||||
// - Forum: ability to manage available forums
|
||||
// - Scopes: ability to manage admin groups & scopes
|
||||
ScopeForumAdmin = "admin.forum.manage"
|
||||
ScopeAdminScopeAdmin = "admin.scope.manage"
|
||||
|
||||
// User account admin
|
||||
// - Impersonate: ability to log in as a user account
|
||||
// - Ban: ability to ban/unban users
|
||||
// - Delete: ability to delete user accounts
|
||||
ScopeUserImpersonate = "admin.user.impersonate"
|
||||
ScopeUserBan = "admin.user.ban"
|
||||
ScopeUserPromote = "admin.user.promote"
|
||||
ScopeUserDelete = "admin.user.delete"
|
||||
|
||||
// Special scope to mark an admin automagically in the Inner Circle
|
||||
ScopeIsInnerCircle = "admin.override.inner-circle"
|
||||
)
|
||||
|
||||
// Number of expected scopes for unit test and validation.
|
||||
const QuantityAdminScopes = 14
|
||||
|
||||
// The specially named Superusers group.
|
||||
const AdminGroupSuperusers = "Superusers"
|
||||
|
||||
// ListAdminScopes returns the listing of all available admin scopes.
|
||||
func ListAdminScopes() []string {
|
||||
return []string{
|
||||
ScopeChatModerator,
|
||||
ScopeForumModerator,
|
||||
ScopePhotoModerator,
|
||||
ScopeCircleModerator,
|
||||
ScopeCertificationApprove,
|
||||
ScopeCertificationList,
|
||||
ScopeCertificationView,
|
||||
ScopeForumAdmin,
|
||||
ScopeAdminScopeAdmin,
|
||||
ScopeUserImpersonate,
|
||||
ScopeUserBan,
|
||||
ScopeUserDelete,
|
||||
ScopeUserPromote,
|
||||
ScopeIsInnerCircle,
|
||||
}
|
||||
}
|
21
pkg/config/admin_scopes_test.go
Normal file
21
pkg/config/admin_scopes_test.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
)
|
||||
|
||||
// TestAdminScopesCount validates that all named admin scopes are
|
||||
// returned by the scope list function.
|
||||
func TestAdminScopesCount(t *testing.T) {
|
||||
var scopes = config.ListAdminScopes()
|
||||
if len(scopes) != config.QuantityAdminScopes {
|
||||
t.Errorf(
|
||||
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
|
||||
"Expected %d, got %d",
|
||||
config.QuantityAdminScopes,
|
||||
len(scopes),
|
||||
)
|
||||
}
|
||||
}
|
195
pkg/controller/admin/scopes.go
Normal file
195
pkg/controller/admin/scopes.go
Normal file
|
@ -0,0 +1,195 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Scopes controller (/admin/scopes)
|
||||
func Scopes() http.HandlerFunc {
|
||||
tmpl := templates.Must("admin/scopes.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query parameters.
|
||||
var (
|
||||
intent = r.FormValue("intent")
|
||||
editGroupIDStr = r.FormValue("id")
|
||||
)
|
||||
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
||||
}
|
||||
|
||||
_ = currentUser
|
||||
|
||||
// List all of the admin users, groups & scopes.
|
||||
adminUsers, err := models.ListAdminUsers()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "ListAdminUsers: %s", err)
|
||||
}
|
||||
adminGroups, err := models.ListAdminGroups()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "ListAdminGroups: %s", err)
|
||||
}
|
||||
|
||||
// Does the Superusers group exist yet?
|
||||
var needSuperuserInit = true
|
||||
for _, group := range adminGroups {
|
||||
if group.Name == config.AdminGroupSuperusers {
|
||||
needSuperuserInit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that the Edit Group ID exists.
|
||||
var (
|
||||
editGroupID int
|
||||
editGroup = &models.AdminGroup{}
|
||||
)
|
||||
if editGroupIDStr != "" {
|
||||
groupID, err := strconv.Atoi(editGroupIDStr)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Group ID is not a valid integer")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, group := range adminGroups {
|
||||
if group.ID == uint64(groupID) {
|
||||
editGroup = group
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && groupID > 0 {
|
||||
session.FlashError(w, r, "Group ID not found.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
editGroupID = groupID
|
||||
}
|
||||
|
||||
// POST event handlers.
|
||||
if r.Method == http.MethodPost {
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeAdminScopeAdmin) && intent != "init-superusers" {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeAdminScopeAdmin)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
switch intent {
|
||||
case "init-superusers":
|
||||
// Initialize the Superusers group, if it does not already exist.
|
||||
if !needSuperuserInit {
|
||||
session.FlashError(w, r, "Could not initialize the Superusers group: it already exists.")
|
||||
break
|
||||
}
|
||||
|
||||
group, err := models.CreateAdminGroup(config.AdminGroupSuperusers, []string{"*"})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't create Superusers group: %s", err)
|
||||
break
|
||||
}
|
||||
|
||||
// Add the current admin user to it.
|
||||
if err := group.AddUser(currentUser); err != nil {
|
||||
session.FlashError(w, r, "Couldn't add you to the Superusers group: %s", err)
|
||||
break
|
||||
}
|
||||
|
||||
session.Flash(w, r, "The Superusers group has been initialized and you placed in it.")
|
||||
case "save":
|
||||
// Create or Save an AdminGroup.
|
||||
var (
|
||||
groupName = r.PostFormValue("name")
|
||||
groupScopes = strings.Split(r.PostFormValue("scopes"), "\n")
|
||||
groupUsers = r.PostForm["username"]
|
||||
)
|
||||
|
||||
if editGroupID == 0 {
|
||||
// New group: easiest option.
|
||||
group, err := models.CreateAdminGroup(groupName, groupScopes)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't create new group: %s", err)
|
||||
break
|
||||
}
|
||||
|
||||
// Apply the user list to it.
|
||||
added, removed, err := group.ReplaceUsers(groupUsers)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't save users in this group: %s", err)
|
||||
break
|
||||
}
|
||||
|
||||
session.Flash(w, r, "Saved admin group with %d scopes.", len(groupScopes))
|
||||
if len(added) > 0 {
|
||||
session.Flash(w, r, "Added %s to the group.", strings.Join(added, ", "))
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
session.Flash(w, r, "Removed %s from the group.", strings.Join(removed, ", "))
|
||||
}
|
||||
} else {
|
||||
// Updating the existing group.
|
||||
if err := editGroup.ReplaceScopes(groupScopes); err != nil {
|
||||
session.FlashError(w, r, "Couldn't replace scopes: %s", err)
|
||||
break
|
||||
}
|
||||
|
||||
added, removed, err := editGroup.ReplaceUsers(groupUsers)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't save users in this group: %s", err)
|
||||
break
|
||||
}
|
||||
|
||||
session.Flash(w, r, "Saved admin group with %d scopes.", len(groupScopes))
|
||||
if len(added) > 0 {
|
||||
session.Flash(w, r, "Added %s to the group.", strings.Join(added, ", "))
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
session.Flash(w, r, "Removed %s from the group.", strings.Join(removed, ", "))
|
||||
}
|
||||
}
|
||||
case "delete":
|
||||
if editGroupID == 0 {
|
||||
session.FlashError(w, r, "Can't delete group: no group ID")
|
||||
} else {
|
||||
err := editGroup.Delete()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't delete group: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Group deleted!")
|
||||
}
|
||||
}
|
||||
default:
|
||||
session.FlashError(w, r, "Unsupported intent: %s", intent)
|
||||
}
|
||||
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Intent": intent,
|
||||
"AdminUsers": adminUsers,
|
||||
"AdminGroups": adminGroups,
|
||||
"AdminScopes": config.ListAdminScopes(),
|
||||
"NeedSuperuserInit": needSuperuserInit,
|
||||
"EditGroupID": editGroupID,
|
||||
"EditGroup": editGroup,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
|
@ -47,6 +48,13 @@ func UserActions() http.HandlerFunc {
|
|||
|
||||
switch intent {
|
||||
case "impersonate":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeUserImpersonate) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserImpersonate)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
if confirm {
|
||||
if err := session.ImpersonateUser(w, r, user, currentUser, reason); err != nil {
|
||||
session.FlashError(w, r, "Failed to impersonate user: %s", err)
|
||||
|
@ -57,6 +65,13 @@ func UserActions() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
case "ban":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeUserBan) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserBan)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
if confirm {
|
||||
status := r.PostFormValue("status")
|
||||
if status == "active" {
|
||||
|
@ -71,6 +86,13 @@ func UserActions() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
case "promote":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeUserPromote) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPromote)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
if confirm {
|
||||
action := r.PostFormValue("action")
|
||||
user.IsAdmin = action == "promote"
|
||||
|
@ -80,6 +102,13 @@ func UserActions() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
case "delete":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeUserDelete) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserDelete)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
if confirm {
|
||||
if err := deletion.DeleteUser(user); err != nil {
|
||||
session.FlashError(w, r, "Failed when deleting the user: %s", err)
|
||||
|
|
|
@ -82,7 +82,7 @@ func Landing() http.HandlerFunc {
|
|||
|
||||
// Create the JWT claims.
|
||||
claims := Claims{
|
||||
IsAdmin: currentUser.IsAdmin,
|
||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||
Avatar: avatar,
|
||||
ProfileURL: "/u/" + currentUser.Username,
|
||||
Nickname: currentUser.NameOrUsername(),
|
||||
|
|
|
@ -115,7 +115,7 @@ func NewPost() http.HandlerFunc {
|
|||
comment = found
|
||||
|
||||
// Verify that it is indeed OUR comment.
|
||||
if currentUser.ID != comment.UserID && !currentUser.IsAdmin {
|
||||
if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -180,8 +180,33 @@ func AdminCertification() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
|
||||
}
|
||||
|
||||
// Scope check based on view.
|
||||
switch view {
|
||||
case "pending":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeCertificationApprove) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
case "approved", "rejected":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeCertificationList) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationList)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Short circuit the GET view for username/email search (exact match)
|
||||
if username := r.FormValue("username"); username != "" {
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeCertificationView) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationView)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Username or email '%s' not found.", username)
|
||||
|
@ -210,6 +235,13 @@ func AdminCertification() http.HandlerFunc {
|
|||
|
||||
// Making a verdict?
|
||||
if r.Method == http.MethodPost {
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeCertificationApprove) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
comment = r.PostFormValue("comment")
|
||||
verdict = r.PostFormValue("verdict")
|
||||
|
|
|
@ -57,6 +57,11 @@ func SiteGallery() http.HandlerFunc {
|
|||
// Is the current viewer shy?
|
||||
var isShy = currentUser.IsShy()
|
||||
|
||||
// Admin scope warning.
|
||||
if adminView && !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopePhotoModerator)
|
||||
}
|
||||
|
||||
// Get the page of photos.
|
||||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
|
|
|
@ -95,7 +95,7 @@ func UserPhotos() http.HandlerFunc {
|
|||
|
||||
// What set of visibilities to query?
|
||||
visibility := []models.PhotoVisibility{models.PhotoPublic}
|
||||
if isOwnPhotos || isGrantee || currentUser.IsAdmin {
|
||||
if isOwnPhotos || isGrantee || currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
|
||||
} else if models.AreFriends(user.ID, currentUser.ID) {
|
||||
visibility = append(visibility, models.PhotoFriends)
|
||||
|
|
|
@ -60,7 +60,7 @@ func LoginRequired(handler http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
// AdminRequired middleware.
|
||||
func AdminRequired(handler http.Handler) http.Handler {
|
||||
func AdminRequired(scope string, handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// User must be logged in.
|
||||
|
@ -77,12 +77,22 @@ func AdminRequired(handler http.Handler) http.Handler {
|
|||
|
||||
// Admin required.
|
||||
if !currentUser.IsAdmin {
|
||||
log.Error("AdminRequired: %s", err)
|
||||
errhandler := templates.MakeErrorPage("Admin Required", "You do not have permission for this page.", http.StatusForbidden)
|
||||
errhandler.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the admin scope.
|
||||
if scope != "" && !currentUser.HasAdminScope(scope) {
|
||||
errhandler := templates.MakeErrorPage(
|
||||
"Admin Scope Required",
|
||||
"Missing required admin scope: "+scope,
|
||||
http.StatusForbidden,
|
||||
)
|
||||
errhandler.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
|
290
pkg/models/admin_scopes.go
Normal file
290
pkg/models/admin_scopes.go
Normal file
|
@ -0,0 +1,290 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminGroup table gives a name to a set of admin permission scopes.
|
||||
type AdminGroup struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Name string `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Scopes []AdminScope `gorm:"foreignKey:GroupID"`
|
||||
Users []*User `gorm:"many2many:admin_group_users;"`
|
||||
}
|
||||
|
||||
// AdminScope table maps admin group IDs to named scopes (which may contain wildcards).
|
||||
type AdminScope struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
GroupID uint64 `gorm:"index"`
|
||||
Scope string
|
||||
}
|
||||
|
||||
// Preload related tables for the AdminGroup.
|
||||
func (g *AdminGroup) Preload() *gorm.DB {
|
||||
return DB.Preload("Scopes").Preload("Users")
|
||||
}
|
||||
|
||||
// HasAdminScope returns whether the user is an admin and has the requested scope.
|
||||
func (u *User) HasAdminScope(scope string) bool {
|
||||
if !u.IsAdmin {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, group := range u.AdminGroups {
|
||||
for _, compare := range group.Scopes {
|
||||
// Scope has a wildcard?
|
||||
if strings.ContainsRune(compare.Scope, '*') {
|
||||
re := regexp.MustCompile(
|
||||
fmt.Sprintf(`^%s$`, strings.ReplaceAll(compare.Scope, "*", ".+?")),
|
||||
)
|
||||
if res := re.FindStringSubmatch(scope); len(res) > 0 {
|
||||
log.Debug("Regexp scope '%s' matched requested '%s'", compare.Scope, scope)
|
||||
return true
|
||||
}
|
||||
} else if compare.Scope == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ListAdminGroups returns the list of admin group (names) the user belongs to.
|
||||
func (u *User) ListAdminGroups() []string {
|
||||
var names = []string{}
|
||||
for _, group := range u.AdminGroups {
|
||||
names = append(names, group.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// ListAdminScopes returns the list of admin group (names) the user belongs to.
|
||||
func (u *User) ListAdminScopes() []string {
|
||||
var (
|
||||
names = []string{}
|
||||
distinct = map[string]interface{}{}
|
||||
)
|
||||
for _, group := range u.AdminGroups {
|
||||
for _, scope := range group.Scopes {
|
||||
if _, ok := distinct[scope.Scope]; ok {
|
||||
continue
|
||||
}
|
||||
distinct[scope.Scope] = nil
|
||||
names = append(names, scope.Scope)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// CreateAdminGroup inserts a new admin group.
|
||||
func CreateAdminGroup(name string, scopes []string) (*AdminGroup, error) {
|
||||
// Verify the name is unique.
|
||||
if _, err := FindAdminGroup(name); err == nil {
|
||||
return nil, fmt.Errorf("that group name already exists: %s", err)
|
||||
}
|
||||
|
||||
g := &AdminGroup{
|
||||
Name: name,
|
||||
Scopes: []AdminScope{},
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "" {
|
||||
g.Scopes = append(g.Scopes, AdminScope{
|
||||
Scope: scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result := DB.Create(g)
|
||||
return g, result.Error
|
||||
}
|
||||
|
||||
// FindAdminGroup by name.
|
||||
func FindAdminGroup(name string) (*AdminGroup, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("username is required")
|
||||
}
|
||||
|
||||
g := &AdminGroup{}
|
||||
result := g.Preload().Where("name = ?", name).Limit(1).First(g)
|
||||
return g, result.Error
|
||||
}
|
||||
|
||||
// ListAdminGroups returns all admin groups.
|
||||
func ListAdminGroups() ([]*AdminGroup, error) {
|
||||
var groups = []*AdminGroup{}
|
||||
|
||||
result := (&AdminGroup{}).Preload().Order("name asc").Find(&groups)
|
||||
return groups, result.Error
|
||||
}
|
||||
|
||||
// ListAdminUsers returns all admin user accounts.
|
||||
func ListAdminUsers() ([]*User, error) {
|
||||
var users = []*User{}
|
||||
|
||||
result := (&User{}).Preload().Where("is_admin is true").Order("id asc").Find(&users)
|
||||
return users, result.Error
|
||||
}
|
||||
|
||||
// ScopesString returns the scopes as a newline separated string.
|
||||
func (g *AdminGroup) ScopesString() string {
|
||||
var scopes = []string{}
|
||||
for _, scope := range g.Scopes {
|
||||
scopes = append(scopes, scope.Scope)
|
||||
}
|
||||
return strings.Join(scopes, "\n")
|
||||
}
|
||||
|
||||
// ReplaceScopes sets new scopes for the admin group.
|
||||
func (g *AdminGroup) ReplaceScopes(scopes []string) error {
|
||||
// Delete the original scopes.
|
||||
var replace = []AdminScope{}
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "" {
|
||||
replace = append(replace, AdminScope{
|
||||
Scope: scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err := DB.Model(g).Association("Scopes").Replace(replace)
|
||||
|
||||
// Cleanup orphaned scopes.
|
||||
if result := DB.Where("group_id IS NULL").Delete(&AdminScope{}); result.Error != nil {
|
||||
log.Error("AdminGroup.ReplaceScopes: cleanup orphaned scopes: %s", result.Error)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// HasAdmin returns whether the given username is a member of the group.
|
||||
func (g *AdminGroup) HasAdmin(username string) bool {
|
||||
for _, user := range g.Users {
|
||||
if user.Username == username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReplaceUsers easily adds or removes admin users from a group.
|
||||
//
|
||||
// Post the full list of usernames who should be in the group, and it will add or
|
||||
// remove users as needed and return which users were changed.
|
||||
func (g *AdminGroup) ReplaceUsers(usernames []string) (added, removed []string, err error) {
|
||||
added = []string{}
|
||||
removed = []string{}
|
||||
|
||||
// Map who is currently in the group.
|
||||
var currentUsernames = map[string]interface{}{}
|
||||
var allUsernames = []string{}
|
||||
for _, user := range g.Users {
|
||||
currentUsernames[user.Username] = nil
|
||||
allUsernames = append(allUsernames, user.Username)
|
||||
}
|
||||
|
||||
// Map the incoming username list.
|
||||
var incomingUsernames = map[string]interface{}{}
|
||||
|
||||
// Who needs added?
|
||||
for _, username := range usernames {
|
||||
incomingUsernames[username] = nil
|
||||
allUsernames = append(allUsernames, username)
|
||||
if _, ok := currentUsernames[username]; !ok {
|
||||
added = append(added, username)
|
||||
}
|
||||
}
|
||||
|
||||
// Who needs removed?
|
||||
for username := range currentUsernames {
|
||||
if _, ok := incomingUsernames[username]; !ok {
|
||||
removed = append(removed, username)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("AdminGroup(%s).ReplaceUsers: complete list %s (adding: %s) (removing: %s)",
|
||||
g.Name,
|
||||
usernames,
|
||||
added,
|
||||
removed,
|
||||
)
|
||||
|
||||
// Select all affected users from the DB.
|
||||
usermap, err := MapUsersByUsername(allUsernames)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Do the needful.
|
||||
for _, username := range added {
|
||||
log.Info("AdminGroup(%s).ReplaceUsers: ADD user %s", g.Name, username)
|
||||
if err := g.AddUser(usermap[username]); err != nil {
|
||||
return added, removed, fmt.Errorf("adding user %s: %s", username, err)
|
||||
}
|
||||
}
|
||||
for _, username := range removed {
|
||||
log.Info("AdminGroup(%s).ReplaceUsers: REMOVE user %s", g.Name, username)
|
||||
if err := g.RemoveUser(usermap[username]); err != nil {
|
||||
return added, removed, fmt.Errorf("removing user %s: %s", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// AddUser puts the given user into the admin group.
|
||||
func (g *AdminGroup) AddUser(user *User) error {
|
||||
if user.AdminGroups == nil {
|
||||
user.AdminGroups = []*AdminGroup{}
|
||||
}
|
||||
|
||||
// Already exists?
|
||||
for _, group := range user.AdminGroups {
|
||||
if group.ID == g.ID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Insert them.
|
||||
user.AdminGroups = append(user.AdminGroups, g)
|
||||
return user.Save()
|
||||
}
|
||||
|
||||
// RemoveUser removes a user from the admin group.
|
||||
func (g *AdminGroup) RemoveUser(user *User) error {
|
||||
if user.AdminGroups == nil {
|
||||
return errors.New("user had no admin groups")
|
||||
}
|
||||
|
||||
return DB.Model(user).Association("AdminGroups").Delete(g)
|
||||
}
|
||||
|
||||
// Delete the AdminGroup.
|
||||
func (g *AdminGroup) Delete() error {
|
||||
// Remove scopes.
|
||||
if result := DB.Where("group_id = ?", g.ID).Delete(&AdminScope{}); result.Error != nil {
|
||||
return fmt.Errorf("can't remove scopes for group ID %d: %s", g.ID, result.Error)
|
||||
}
|
||||
|
||||
// Remove users.
|
||||
if _, _, err := g.ReplaceUsers([]string{}); err != nil {
|
||||
return fmt.Errorf("can't remove users for group ID %d: %s", g.ID, err)
|
||||
}
|
||||
|
||||
return DB.Delete(g).Error
|
||||
}
|
|
@ -149,7 +149,7 @@ func FriendIDsInCircle(userId uint64) []uint64 {
|
|||
).Scan(&userIDs)
|
||||
|
||||
if err.Error != nil {
|
||||
log.Error("SQL error collecting circle FriendIDs for %d: %s", userId, err)
|
||||
log.Error("SQL error collecting circle FriendIDs for %d: %s", userId, err.Error)
|
||||
}
|
||||
|
||||
return userIDs
|
||||
|
@ -174,7 +174,7 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 {
|
|||
).Scan(&userIDs)
|
||||
|
||||
if err.Error != nil {
|
||||
log.Error("SQL error collecting explicit FriendIDs for %d: %s", userId, err)
|
||||
log.Error("SQL error collecting explicit FriendIDs for %d: %s", userId, err.Error)
|
||||
}
|
||||
|
||||
return userIDs
|
||||
|
|
|
@ -26,4 +26,6 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&CommentPhoto{})
|
||||
DB.AutoMigrate(&Poll{})
|
||||
DB.AutoMigrate(&PollVote{})
|
||||
DB.AutoMigrate(&AdminGroup{})
|
||||
DB.AutoMigrate(&AdminScope{})
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
@ -302,7 +303,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
}
|
||||
|
||||
// Admins see everything on the site (only an admin user can get an admin view).
|
||||
adminView = user.IsAdmin && adminView
|
||||
adminView = user.HasAdminScope(config.ScopePhotoModerator) && adminView
|
||||
|
||||
// Include ourself in our friend IDs.
|
||||
friendIDs = append(friendIDs, userID)
|
||||
|
|
|
@ -269,7 +269,7 @@ func MapThreadStatistics(threads []*Thread) ThreadStatsMap {
|
|||
).Group("table_id").Scan(&groups)
|
||||
|
||||
if err != nil {
|
||||
log.Error("MapThreadStatistics: SQL error: %s", err)
|
||||
log.Error("MapThreadStatistics: SQL error: %s", err.Error)
|
||||
}
|
||||
|
||||
// Map the results in.
|
||||
|
|
|
@ -36,7 +36,8 @@ type User struct {
|
|||
// Relational tables.
|
||||
ProfileField []ProfileField
|
||||
ProfilePhotoID *uint64
|
||||
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
|
||||
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
|
||||
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;"`
|
||||
|
||||
// Current user's relationship to this user -- not stored in DB.
|
||||
UserRelationship UserRelationship `gorm:"-"`
|
||||
|
@ -62,7 +63,7 @@ var UserVisibilityOptions = []UserVisibility{
|
|||
|
||||
// Preload related tables for the user (classmethod).
|
||||
func (u *User) Preload() *gorm.DB {
|
||||
return DB.Preload("ProfileField").Preload("ProfilePhoto")
|
||||
return DB.Preload("ProfileField").Preload("ProfilePhoto").Preload("AdminGroups.Scopes")
|
||||
}
|
||||
|
||||
// UserStatus options.
|
||||
|
@ -338,6 +339,47 @@ func (um UserMap) Get(id uint64) *User {
|
|||
return nil
|
||||
}
|
||||
|
||||
// MapUsersByUsername looks up a set of users in bulk and returns a UsernameMap suitable for templates.
|
||||
//
|
||||
// It is like MapUsers but by username instead of ID.
|
||||
func MapUsersByUsername(usernames []string) (UsernameMap, error) {
|
||||
var (
|
||||
usermap = UsernameMap{}
|
||||
set = map[string]interface{}{}
|
||||
distinct = []string{}
|
||||
)
|
||||
|
||||
// Uniqueify users.
|
||||
for _, uid := range usernames {
|
||||
if _, ok := set[uid]; ok {
|
||||
continue
|
||||
}
|
||||
set[uid] = nil
|
||||
distinct = append(distinct, uid)
|
||||
}
|
||||
|
||||
var (
|
||||
users = []*User{}
|
||||
result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users)
|
||||
)
|
||||
|
||||
if result.Error == nil {
|
||||
for _, row := range users {
|
||||
usermap[row.Username] = row
|
||||
}
|
||||
}
|
||||
|
||||
// Assert we got the expected count.
|
||||
if len(usermap) != len(distinct) {
|
||||
return usermap, fmt.Errorf("didn't get all expected users (expected %d, got %d)", len(distinct), len(usermap))
|
||||
}
|
||||
|
||||
return usermap, result.Error
|
||||
}
|
||||
|
||||
// UsernameMap helps map a set of users to look up by ID.
|
||||
type UsernameMap map[string]*User
|
||||
|
||||
// NameOrUsername returns the name (if not null or empty) or the username.
|
||||
func (u *User) NameOrUsername() string {
|
||||
if u.Name != nil && len(*u.Name) > 0 {
|
||||
|
|
|
@ -3,6 +3,7 @@ package models
|
|||
import (
|
||||
"errors"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
)
|
||||
|
||||
|
@ -10,7 +11,7 @@ import (
|
|||
|
||||
// IsInnerCircle returns whether the user is in the inner circle (including if the user is an admin, who is always in the inner circle).
|
||||
func (u *User) IsInnerCircle() bool {
|
||||
return u.InnerCircle || u.IsAdmin
|
||||
return u.InnerCircle || u.HasAdminScope(config.ScopeIsInnerCircle)
|
||||
}
|
||||
|
||||
// AddToInnerCircle adds a user to the circle, sending them a notification in the process.
|
||||
|
@ -57,7 +58,7 @@ func RemoveFromInnerCircle(u *User) error {
|
|||
"user_id = ? AND visibility = ?",
|
||||
u.ID, PhotoInnerCircle,
|
||||
).Update("visibility", PhotoFriends); err != nil {
|
||||
log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err)
|
||||
log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err.Error)
|
||||
}
|
||||
|
||||
// Revoke any historic notification about the circle.
|
||||
|
|
|
@ -80,13 +80,14 @@ func New() http.Handler {
|
|||
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
|
||||
|
||||
// Admin endpoints.
|
||||
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
|
||||
mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification()))
|
||||
mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback()))
|
||||
mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions()))
|
||||
mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage()))
|
||||
mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit()))
|
||||
mux.Handle("/inner-circle/remove", middleware.AdminRequired(account.RemoveCircle()))
|
||||
mux.Handle("/admin", middleware.AdminRequired("", admin.Dashboard()))
|
||||
mux.Handle("/admin/scopes", middleware.AdminRequired("", admin.Scopes()))
|
||||
mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification()))
|
||||
mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback()))
|
||||
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
|
||||
mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage()))
|
||||
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit()))
|
||||
mux.Handle("/inner-circle/remove", middleware.AdminRequired(config.ScopeCircleModerator, account.RemoveCircle()))
|
||||
|
||||
// JSON API endpoints.
|
||||
mux.HandleFunc("/v1/version", api.Version())
|
||||
|
|
|
@ -142,6 +142,12 @@
|
|||
Gallery: Admin View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/scopes">
|
||||
<i class="fa fa-gavel mr-2"></i>
|
||||
Admin Permissions Management
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
239
web/templates/admin/scopes.html
Normal file
239
web/templates/admin/scopes.html
Normal file
|
@ -0,0 +1,239 @@
|
|||
{{define "title"}}Admin - Scopes & Permissions{{end}}
|
||||
{{define "content"}}
|
||||
{{$Root := .}}
|
||||
<div class="container">
|
||||
<section class="hero is-danger is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
Scopes & Permissions
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if or (eq .Intent "new") (eq .Intent "edit")}}
|
||||
<!-- Create New/Edit a Group -->
|
||||
<div class="columns mt-2">
|
||||
<div class="column is-three-quarters">
|
||||
<h1 class="title">
|
||||
{{if .EditGroupID}}Edit{{else}}New{{end}} Admin Group
|
||||
</h1>
|
||||
<form action="{{.Request.URL.Path}}" method="POST">
|
||||
{{InputCSRF}}
|
||||
<input type="hidden" name="id" value="{{.EditGroupID}}">
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="name">Group Name</label>
|
||||
<input type="text" class="input"
|
||||
name="name"
|
||||
id="name"
|
||||
autocomplete="off"
|
||||
value="{{.EditGroup.Name}}"
|
||||
placeholder="Forum Moderators"
|
||||
{{if .EditGroupID}}readonly{{end}}>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="scopes">Scopes</label>
|
||||
<textarea class="textarea"
|
||||
cols="80" rows="4"
|
||||
name="scopes"
|
||||
id="scopes"
|
||||
placeholder="one.scope.per.line">{{.EditGroup.ScopesString}}</textarea>
|
||||
<p class="help">
|
||||
Enter scopes one per line. Wildcards (*) may be used, as in <code>social.moderator.*</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Admin Users</label>
|
||||
{{range .AdminUsers}}
|
||||
<div>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="username"
|
||||
value="{{.Username}}"
|
||||
{{if $Root.EditGroup.HasAdmin .Username}}checked{{end}}>
|
||||
{{.Username}}
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="help">
|
||||
Select the admin user(s) who should be a part of this group.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-primary"
|
||||
name="intent"
|
||||
value="save">
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
{{if .EditGroupID}}
|
||||
<button type="submit" class="button is-danger"
|
||||
name="intent"
|
||||
value="delete"
|
||||
onclick="return window.confirm('Are you sure you want to delete this group?')">
|
||||
<i class="fa fa-trash mr-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
<a href="{{.Request.URL.Path}}" class="button is-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="column is-one-quarter">
|
||||
<div class="card">
|
||||
<div class="card-header has-background-info">
|
||||
<p class="card-header-title has-text-light">
|
||||
Admin Scopes
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p class="block">
|
||||
The complete listing of available admin scopes on the website
|
||||
are as follows:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
{{range .AdminScopes}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="p-4">
|
||||
|
||||
<p class="block">
|
||||
This page allows you to manage the nuanced permissions for admin accounts on {{PrettyTitle}}.
|
||||
Permissions are managed by placing admins into groups, and in turn, assigning one or more
|
||||
scopes to each group.
|
||||
</p>
|
||||
|
||||
<!-- Superusers group init? -->
|
||||
{{if .NeedSuperuserInit}}
|
||||
<div class="notification is-warning content">
|
||||
<p>
|
||||
The <strong>Superusers</strong> group was not found! Click the button below
|
||||
to initialize the Superusers group and add yourself to it automatically.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<form action="{{.Request.URL.Path}}" method="POST">
|
||||
{{InputCSRF}}
|
||||
<button type="submit"
|
||||
name="intent"
|
||||
value="init-superusers"
|
||||
class="button is-danger">
|
||||
Initialize the Superusers Group
|
||||
</button>
|
||||
</form>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<hr>
|
||||
|
||||
<h1 class="title">Admin Users</h1>
|
||||
|
||||
<p class="block">
|
||||
Found <strong>{{len .AdminUsers}}</strong> admin user account{{Pluralize (len .AdminUsers)}}.
|
||||
</p>
|
||||
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Admin Groups</th>
|
||||
<th>Scopes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AdminUsers}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td><a href="/u/{{.Username}}">{{.Username}}</a></td>
|
||||
<td><a href="mailto:{{.Email}}">{{.Email}}</a></td>
|
||||
<td>
|
||||
<ul>
|
||||
{{range .ListAdminGroups}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{{range .ListAdminScopes}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h1 class="title">Admin Groups</h1>
|
||||
|
||||
<p class="block">
|
||||
Found <strong>{{len .AdminGroups}}</strong> admin group{{Pluralize (len .AdminUsers)}}.
|
||||
<a href="{{.Request.URL.Path}}?intent=new">Create new group.</a>
|
||||
</p>
|
||||
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Scopes</th>
|
||||
<th>Users</th>
|
||||
<th>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AdminGroups}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Name}}</td>
|
||||
<td>
|
||||
<ul>
|
||||
{{range .Scopes}}
|
||||
<li>{{.Scope}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{{range .Users}}
|
||||
<li>
|
||||
<a href="/u/{{.Username}}">{{.Username}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{$Root.Request.URL.Path}}?intent=edit&id={{.ID}}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
|
@ -258,7 +258,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
{{if .CurrentUser.HasAdminScope "social.moderator.photo"}}
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label has-text-danger" for="admin_view">Admin view:</label>
|
||||
|
|
Loading…
Reference in New Issue
Block a user