dashboard: change user roles

* implement SetRole on sqlite
* add dropdown form to members table
* add http endpoint for processing
* Add comment to denied keys overview
* update ban remove confirm page
This commit is contained in:
Henry 2021-03-22 10:32:49 +01:00
parent a0be3e998b
commit 4f723ab050
14 changed files with 336 additions and 25 deletions

View File

@ -56,7 +56,10 @@ type MembersService interface {
// RemoveID removes the feed for the ID from the list.
RemoveID(context.Context, int64) error
// SetRole
// SetRole changes the role of the passed member id.
// It will return an error if the member doesn't exist.
// It should also return an error if call would remove the last admin,
// since only admins can change roles doing so would leave the room in a crippled state.
SetRole(context.Context, int64, Role) error
}

View File

@ -26,6 +26,19 @@ type FakeMembersService struct {
result1 int64
result2 error
}
ChangeRoleStub func(context.Context, int64, roomdb.Role) error
changeRoleMutex sync.RWMutex
changeRoleArgsForCall []struct {
arg1 context.Context
arg2 int64
arg3 roomdb.Role
}
changeRoleReturns struct {
result1 error
}
changeRoleReturnsOnCall map[int]struct {
result1 error
}
GetByFeedStub func(context.Context, refs.FeedRef) (roomdb.Member, error)
getByFeedMutex sync.RWMutex
getByFeedArgsForCall []struct {
@ -175,6 +188,69 @@ func (fake *FakeMembersService) AddReturnsOnCall(i int, result1 int64, result2 e
}{result1, result2}
}
func (fake *FakeMembersService) ChangeRole(arg1 context.Context, arg2 int64, arg3 roomdb.Role) error {
fake.changeRoleMutex.Lock()
ret, specificReturn := fake.changeRoleReturnsOnCall[len(fake.changeRoleArgsForCall)]
fake.changeRoleArgsForCall = append(fake.changeRoleArgsForCall, struct {
arg1 context.Context
arg2 int64
arg3 roomdb.Role
}{arg1, arg2, arg3})
stub := fake.ChangeRoleStub
fakeReturns := fake.changeRoleReturns
fake.recordInvocation("ChangeRole", []interface{}{arg1, arg2, arg3})
fake.changeRoleMutex.Unlock()
if stub != nil {
return stub(arg1, arg2, arg3)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeMembersService) ChangeRoleCallCount() int {
fake.changeRoleMutex.RLock()
defer fake.changeRoleMutex.RUnlock()
return len(fake.changeRoleArgsForCall)
}
func (fake *FakeMembersService) ChangeRoleCalls(stub func(context.Context, int64, roomdb.Role) error) {
fake.changeRoleMutex.Lock()
defer fake.changeRoleMutex.Unlock()
fake.ChangeRoleStub = stub
}
func (fake *FakeMembersService) ChangeRoleArgsForCall(i int) (context.Context, int64, roomdb.Role) {
fake.changeRoleMutex.RLock()
defer fake.changeRoleMutex.RUnlock()
argsForCall := fake.changeRoleArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
}
func (fake *FakeMembersService) ChangeRoleReturns(result1 error) {
fake.changeRoleMutex.Lock()
defer fake.changeRoleMutex.Unlock()
fake.ChangeRoleStub = nil
fake.changeRoleReturns = struct {
result1 error
}{result1}
}
func (fake *FakeMembersService) ChangeRoleReturnsOnCall(i int, result1 error) {
fake.changeRoleMutex.Lock()
defer fake.changeRoleMutex.Unlock()
fake.ChangeRoleStub = nil
if fake.changeRoleReturnsOnCall == nil {
fake.changeRoleReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.changeRoleReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeMembersService) GetByFeed(arg1 context.Context, arg2 refs.FeedRef) (roomdb.Member, error) {
fake.getByFeedMutex.Lock()
ret, specificReturn := fake.getByFeedReturnsOnCall[len(fake.getByFeedArgsForCall)]
@ -561,6 +637,8 @@ func (fake *FakeMembersService) Invocations() map[string][][]interface{} {
defer fake.invocationsMutex.RUnlock()
fake.addMutex.RLock()
defer fake.addMutex.RUnlock()
fake.changeRoleMutex.RLock()
defer fake.changeRoleMutex.RUnlock()
fake.getByFeedMutex.RLock()
defer fake.getByFeedMutex.RUnlock()
fake.getByIDMutex.RLock()

View File

@ -36,7 +36,7 @@ func TestDeniedKeys(t *testing.T) {
// looks ok at least
created := time.Now()
time.Sleep(time.Second / 2)
time.Sleep(time.Second)
okFeed := refs.FeedRef{ID: bytes.Repeat([]byte("b44d"), 8), Algo: refs.RefAlgoFeedSSB1}
err = db.DeniedKeys.Add(ctx, okFeed, "be gone")
r.NoError(err)
@ -53,7 +53,7 @@ func TestDeniedKeys(t *testing.T) {
r.Len(lst, 1)
r.Equal(okFeed.Ref(), lst[0].PubKey.Ref())
r.Equal("be gone", lst[0].Comment)
r.True(lst[0].CreatedAt.After(created))
r.True(lst[0].CreatedAt.After(created), "not created after the sleep?")
yes := db.DeniedKeys.HasFeed(ctx, okFeed)
r.True(yes)

View File

@ -143,5 +143,30 @@ func (m Members) SetRole(ctx context.Context, id int64, r roomdb.Role) error {
return err
}
panic("TODO")
return transact(m.db, func(tx *sql.Tx) error {
m, err := models.FindMember(ctx, tx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomdb.ErrNotFound
}
return err
}
// find the number of other admins
admins, err := models.Members(
qm.Where("id != ?", id),
qm.Where("role = ?", roomdb.RoleAdmin),
).Count(ctx, tx)
if err != nil {
return err
}
if admins < 1 {
return fmt.Errorf("need at least one other admin")
}
m.Role = int64(r)
_, err = m.Update(ctx, tx, boil.Whitelist("role"))
return err
})
}

View File

@ -136,4 +136,88 @@ func TestMembersByID(t *testing.T) {
_, yes = db.Members.GetByID(ctx, lst[0].ID)
r.Error(yes)
r.NoError(db.Close())
}
func TestMembersSetRole(t *testing.T) {
r := require.New(t)
ctx := context.Background()
testRepo := filepath.Join("testrun", t.Name())
os.RemoveAll(testRepo)
tr := repo.New(testRepo)
db, err := Open(tr)
require.NoError(t, err)
// create two users
feedA := refs.FeedRef{ID: bytes.Repeat([]byte("1"), 32), Algo: refs.RefAlgoFeedSSB1}
idA, err := db.Members.Add(ctx, "user-a", feedA, roomdb.RoleAdmin)
r.NoError(err)
t.Log("member A:", idA)
feedB := refs.FeedRef{ID: bytes.Repeat([]byte("2"), 32), Algo: refs.RefAlgoFeedSSB1}
idB, err := db.Members.Add(ctx, "user-b", feedB, roomdb.RoleModerator)
r.NoError(err)
t.Log("member B:", idB)
// list and check
members, err := db.Members.List(ctx)
r.NoError(err)
r.Len(members, 2)
findMemberWithRole(t, members, idA, roomdb.RoleAdmin)
findMemberWithRole(t, members, idB, roomdb.RoleModerator)
// upgrade B to admin
err = db.Members.SetRole(ctx, idB, roomdb.RoleAdmin)
r.NoError(err)
// list and check
members, err = db.Members.List(ctx)
r.NoError(err)
r.Len(members, 2)
findMemberWithRole(t, members, idA, roomdb.RoleAdmin)
findMemberWithRole(t, members, idB, roomdb.RoleAdmin)
// downgrade A to member
err = db.Members.SetRole(ctx, idA, roomdb.RoleMember)
r.NoError(err)
// list and check
members, err = db.Members.List(ctx)
r.NoError(err)
r.Len(members, 2)
findMemberWithRole(t, members, idA, roomdb.RoleMember)
findMemberWithRole(t, members, idB, roomdb.RoleAdmin)
// can't downgrade B to member (need one admin)
err = db.Members.SetRole(ctx, idB, roomdb.RoleMember)
r.Error(err)
// unchanged
members, err = db.Members.List(ctx)
r.NoError(err)
r.Len(members, 2)
findMemberWithRole(t, members, idA, roomdb.RoleMember)
findMemberWithRole(t, members, idB, roomdb.RoleAdmin)
r.NoError(db.Close())
}
func findMemberWithRole(t *testing.T, members []roomdb.Member, id int64, r roomdb.Role) {
var found = false
for _, m := range members {
if m.ID == id {
if m.Role != r {
t.Errorf("memberd %d has the wrong role (has %s)", m.ID, m.Role)
}
found = true
}
}
if !found {
t.Errorf("memberd %d not in the list", id)
}
}

View File

@ -58,6 +58,26 @@ const (
RoleAdmin
)
func (r *Role) UnmarshalText(text []byte) error {
roleStr := string(text)
switch roleStr {
case RoleAdmin.String():
*r = RoleAdmin
case RoleModerator.String():
*r = RoleModerator
case RoleMember.String():
*r = RoleMember
default:
return fmt.Errorf("unknown member role: %q", roleStr)
}
return nil
}
type ErrAlreadyAdded struct {
Ref refs.FeedRef
}

View File

@ -96,6 +96,7 @@ func Handler(
}
mux.HandleFunc("/members", r.HTML("admin/members.tmpl", mh.overview))
mux.HandleFunc("/members/add", mh.add)
mux.HandleFunc("/members/change-role", mh.changeRole)
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/members-remove-confirm.tmpl", mh.removeConfirm))
mux.HandleFunc("/members/remove", mh.remove)

View File

@ -14,6 +14,7 @@ import (
"github.com/gorilla/csrf"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
"github.com/ssb-ngi-pointer/go-ssb-room/web/user"
)
type membersHandler struct {
@ -70,6 +71,49 @@ func (h membersHandler) add(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, redirectToMembers, http.StatusFound)
}
func (h membersHandler) changeRole(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
// TODO: proper error type
h.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("bad request"))
return
}
currentUser := user.FromContext(req.Context())
if currentUser == nil || currentUser.Role != roomdb.RoleAdmin {
// TODO: proper error type
h.r.Error(w, req, http.StatusForbidden, fmt.Errorf("not an admin"))
return
}
memberID, err := strconv.ParseInt(req.URL.Query().Get("id"), 10, 64)
if err != nil {
// TODO: proper error type
h.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("bad member id: %w", err))
return
}
if err := req.ParseForm(); err != nil {
// TODO: proper error type
h.r.Error(w, req, http.StatusBadRequest, fmt.Errorf("bad request: %w", err))
return
}
var role roomdb.Role
if err := role.UnmarshalText([]byte(req.Form.Get("role"))); err != nil {
// TODO: proper error type
h.r.Error(w, req, http.StatusBadRequest, err)
return
}
if err := h.db.SetRole(req.Context(), memberID, role); err != nil {
// TODO: proper error type
h.r.Error(w, req, http.StatusInternalServerError, fmt.Errorf("failed to change member role: %w", err))
return
}
http.Redirect(w, req, redirectToMembers, http.StatusTemporaryRedirect)
}
func (h membersHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
lst, err := h.db.List(req.Context())
if err != nil {
@ -87,6 +131,8 @@ func (h membersHandler) overview(rw http.ResponseWriter, req *http.Request) (int
pageData[csrf.TemplateTag] = csrf.TemplateField(req)
pageData["AllRoles"] = []roomdb.Role{roomdb.RoleMember, roomdb.RoleModerator, roomdb.RoleAdmin}
return pageData, nil
}

View File

@ -7,6 +7,10 @@ GenericLanguage = "Language"
PageNotFound = "The requested page was not found."
RoleMember = "Member"
RoleModerator = "Moderator"
RoleAdmin = "Admin"
LandingTitle = "ohai my room"
LandingWelcome = "Landing welcome here"
@ -22,11 +26,13 @@ AdminAliasesTitle = "Aliases"
AdminAliasesWelcome = "Here you can see and revoke the registered aliases of this room."
AdminAliasesRevoke = "Revoke"
AdminDeniedKeysTitle = "Denied Public Keys"
AdminDeniedKeysWelcome = "This page can be used to ban/block SSB IDs so that they can't access the room any more."
AdminDeniedKeysTitle = "Banned"
AdminDeniedKeysWelcome = "This page can be used to ban SSB IDs so that they can't access the room any more."
AdminDeniedKeysAdd = "Add"
AdminDeniedKeysRemove = "Remove"
AdminDeniedKeysRemoveConfirmWelcome = "Are you sure you want to remove this member? They will lose their alias, if they have one."
AdminDeniedKeysComment = "Comment"
AdminDeniedKeysCommentDescription = "The person who added this ban, added the following comment"
AdminDeniedKeysRemoveConfirmWelcome = "Are you sure you want to remove this ban? They will will be able to access the room again."
AdminDeniedKeysRemoveConfirmTitle = "Confirm member removal"
AdminMembersTitle = "Members"

View File

@ -20,6 +20,7 @@ const (
AdminMembersOverview = "admin:members:overview"
AdminMembersAdd = "admin:members:add"
AdminMembersChangeRole = "admin:members:change-role"
AdminMembersRemoveConfirm = "admin:members:remove:confirm"
AdminMembersRemove = "admin:members:remove"
@ -54,6 +55,7 @@ func Admin(m *mux.Router) *mux.Router {
m.Path("/members").Methods("GET").Name(AdminMembersOverview)
m.Path("/members/add").Methods("POST").Name(AdminMembersAdd)
m.Path("/members/change-role").Methods("POST").Name(AdminMembersChangeRole)
m.Path("/members/remove/confirm").Methods("GET").Name(AdminMembersRemoveConfirm)
m.Path("/members/remove").Methods("POST").Name(AdminMembersRemove)

View File

@ -12,6 +12,17 @@
class="my-4 font-mono truncate max-w-full text-lg text-gray-700"
>{{.Entry.PubKey.Ref}}</pre>
<div class="has-tooltip">
{{human_time .Entry.CreatedAt}}
<span class="tooltip">{{.Entry.CreatedAt.Format "2006-01-02T15:04:05.00"}}</span>
</div>
<span
id="welcome"
class="text-center"
>{{i18n "AdminDeniedKeysCommentDescription"}}</span>
<p>{{.Entry.Comment}}</p>
<form id="confirm" action="{{urlTo "admin:denied-keys:remove"}}" method="POST">
{{ .csrfField }}
<input type="hidden" name="id" value={{.Entry.ID}}>

View File

@ -23,13 +23,13 @@
type="text"
name="pub_key"
placeholder="@ .ed25519"
class="font-mono truncate flex-auto tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
class="font-mono truncate w-1/2 mr-2 tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
>
<input
type="text"
name="comment"
placeholder="some comment"
class="font-mono truncate flex-auto tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
placeholder="{{i18n "AdminDeniedKeysComment"}}"
class="font-mono truncate w-1/2 tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
>
<input
type="submit"
@ -41,9 +41,13 @@
{{range .Entries}}
<li class="flex flex-row items-center h-12">
<span
class="font-mono truncate flex-auto text-gray-600 tracking-wider"
class="font-mono truncate flex-auto text-gray-600 tracking-wider text-xs"
>{{.PubKey.Ref}}</span>
<span
class="font-mono flex-auto text-gray-600 tracking-wider"
>{{.Comment}}</span>
<a
href="{{urlTo "admin:denied-keys:remove:confirm" "id" .ID}}"
class="pl-4 w-20 py-2 text-center text-gray-400 hover:text-red-600 font-bold cursor-pointer"

View File

@ -23,13 +23,13 @@
type="text"
name="nick"
placeholder="member nickname"
class="font-mono truncate flex-auto tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
class="font-mono truncate w-1/2 tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
>
<input
type="text"
name="pub_key"
placeholder="@ .ed25519"
class="font-mono truncate flex-auto tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
class="font-mono truncate w-1/2 ml-3 tracking-wider h-12 text-gray-900 focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent placeholder-gray-300"
>
<input
type="submit"
@ -38,14 +38,43 @@
>
</div>
</form>
{{range .Entries}}
{{range $index, $member := .Entries}}
<li class="flex flex-row items-center h-12">
<span
class="font-mono truncate flex-auto text-gray-600 tracking-wider"
>{{.PubKey.Ref}}</span>
class="font-mono truncate flex-auto text-gray-600 tracking-wider w-1/3"
>{{$member.Nickname}}</span>
<form
class="change-member-role"
action="{{urlTo "admin:members:change-role" "id" $member.ID}}"
method="POST"
>
{{ $.csrfField }}
<select name="role">
{{range $.AllRoles}}
<option
value="{{.}}"
{{if eq . $member.Role}}
selected
{{else}}
class="changed"
{{end}}
>{{i18n .String}}</option>
{{end}}
</select>
<!-- TODO: I'd like to keep this button hidden until the option is changed.
butt i'm not sure that's possible without client side javascript..?
https://stackoverflow.com/questions/16015933/how-can-i-show-a-hidden-div-when-a-select-option-is-selected
-->
<input type="submit" value="{{i18n "GenericSave"}}">
</form>
<span
class="font-mono truncate flex-auto text-gray-600 text-xs"
>{{$member.PubKey.Ref}}</span>
<a
href="{{urlTo "admin:members:remove:confirm" "id" .ID}}"
href="{{urlTo "admin:members:remove:confirm" "id" $member.ID}}"
class="pl-4 w-20 py-2 text-center text-gray-400 hover:text-red-600 font-bold cursor-pointer"
>{{i18n "AdminMembersRemove"}}</a>
</li>

View File

@ -1,4 +1,5 @@
{{ define "menu" }}
<!-- Icons are taken from https://materialdesignicons.com/ -->
<div class="flex flex-col my-4 w-40 px-2 sm:pl-0">
<a
href="{{urlTo "complete:index"}}"
@ -36,14 +37,6 @@
</svg>{{i18n "AdminMembersTitle"}}
</a>
<a
href="{{urlTo "admin:denied-keys:overview"}}"
class="{{if current_page_is "admin:denied-keys:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"
>
<svg class="text-purple-600 w-4 h-4 mr-1" viewBox="0 0 24 24">
<path fill="currentColor" d="" />
</svg>{{i18n "AdminDeniedKeysTitle"}}
</a>
<a
href="{{urlTo "admin:invites:overview"}}"
@ -54,6 +47,15 @@
</svg>{{i18n "NavAdminInvites"}}
</a>
<a
href="{{urlTo "admin:denied-keys:overview"}}"
class="{{if current_page_is "admin:denied-keys:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"
>
<svg class="text-red-600 w-4 h-4 mr-1" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,0A12,12 0 0,1 24,12A12,12 0 0,1 12,24A12,12 0 0,1 0,12A12,12 0 0,1 12,0M12,2A10,10 0 0,0 2,12C2,14.4 2.85,16.6 4.26,18.33L18.33,4.26C16.6,2.85 14.4,2 12,2M12,22A10,10 0 0,0 22,12C22,9.6 21.15,7.4 19.74,5.67L5.67,19.74C7.4,21.15 9.6,22 12,22Z" />
</svg>{{i18n "AdminDeniedKeysTitle"}}
</a>
<a
href="{{urlTo "complete:notice:list"}}"
class="{{if current_page_is "complete:notice:list"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"