1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-09-01 07:26:23 -07:00

Separate staff account password changing from rank changing, add staff callback function tests

This commit is contained in:
Eggbertx 2025-05-01 14:54:10 -07:00
parent bd1039057b
commit 6fcb5fb262
14 changed files with 730 additions and 447 deletions

View file

@ -30,4 +30,4 @@
color: $a-visited;
}
}
}
}

View file

@ -122,7 +122,7 @@ div#upload-box {
.postblock {
margin-left: 8px;
margin-right: 8px;
width: 100px;
// width: 100px;
}
.post-text, .banned-message {

View file

@ -11,7 +11,7 @@
}
.postblock,
table.mgmt-table tr:first-of-type th {
table.mgmt-table th {
background: colors.$postblock;
font-weight: 700;
}

View file

@ -139,7 +139,6 @@ div#upload-box .upload-x, div#upload-box .upload-filename {
.postblock {
margin-left: 8px;
margin-right: 8px;
width: 100px;
}
.post-text, .banned-message {

View file

@ -53,7 +53,7 @@ div#recent-posts-header {
}
.postblock,
table.mgmt-table tr:first-of-type th {
table.mgmt-table th {
background: #25272D;
font-weight: 700;
}

View file

@ -107,8 +107,16 @@ func (s *Staff) UpdateRank(rank int) error {
if rank < 0 || rank > 3 {
return ErrInvalidStaffRank
}
_, err := ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET global_rank = ? WHERE id = ?", rank, s.ID)
if err != nil {
var err error
if s.ID == 0 {
// ID field not set yet, get it from the DB
s.ID, err = GetStaffID(s.Username)
if err != nil {
return err
}
}
if _, err = ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET global_rank = ? WHERE id = ?", rank, s.ID); err != nil {
return err
}
s.Rank = rank
@ -120,8 +128,17 @@ func (s *Staff) UpdatePassword(password string) error {
if password == "" {
return ErrInvalidStaffPassword
}
var err error
if s.ID == 0 {
// ID field not set yet, get it from the DB
s.ID, err = GetStaffID(s.Username)
if err != nil {
return err
}
}
checksum := gcutil.BcryptSum(password)
_, err := ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET password_checksum = ? WHERE id = ?", checksum, s.ID)
_, err = ExecTimeoutSQL(nil, "UPDATE DBPREFIXstaff SET password_checksum = ? WHERE id = ?", checksum, s.ID)
if err != nil {
return err
}
@ -129,38 +146,10 @@ func (s *Staff) UpdatePassword(password string) error {
return nil
}
// UpdateStaff sets the rank and/or password of the staff account with the given username. If password
// is blank, only the rank will be updated
func UpdateStaff(username string, rank int, password string) error {
// first check if it's a recognized username
id, err := GetStaffID(username)
if err != nil {
return err
}
sqlUpdate := "UPDATE DBPREFIXstaff SET global_rank = ?"
args := []any{rank}
if password != "" {
sqlUpdate += ", password_checksum = ?"
args = append(args, gcutil.BcryptSum(password))
}
sqlUpdate += " WHERE id = ?"
args = append(args, id)
_, err = ExecTimeoutSQL(nil, sqlUpdate, args...)
return err
}
// UpdateStaff sets the password of the staff account with the given username
func UpdatePassword(username string, newPassword string) error {
const sqlUPDATE = `UPDATE DBPREFIXstaff SET password_checksum = ? WHERE id = ?`
id, err := GetStaffID(username)
if err != nil {
return err
}
checksum := gcutil.BcryptSum(newPassword)
_, err = ExecTimeoutSQL(nil, sqlUPDATE, checksum, id)
return err
// UpdateStaffPassword sets the password of the staff account with the given username
func UpdateStaffPassword(username string, newPassword string) error {
staff := Staff{Username: username}
return staff.UpdatePassword(newPassword)
}
// EndStaffSession deletes any session rows associated with the requests session cookie and then

View file

@ -118,46 +118,47 @@ func (u *Upload) IsEmbed() bool {
return strings.HasPrefix(u.Filename, "embed:")
}
// IPBanBase used to composition IPBan and IPBanAudit. It does not represent a SQL table by itself
// IPBanBase is used by IPBan and IPBanAudit. It does not represent a SQL table by itself
type IPBanBase struct {
IsActive bool
IsThreadBan bool
ExpiresAt time.Time
StaffID int
AppealAt time.Time
Permanent bool
StaffNote string
Message string
CanAppeal bool
IsActive bool // sql: is_active
IsThreadBan bool // sql: is_thread_ban
ExpiresAt time.Time // sql: expires_at
StaffID int // sql: staff_id
AppealAt time.Time // sql: appeal_at
Permanent bool // sql: permanent
StaffNote string // sql: staff_note
Message string // sql: message
CanAppeal bool // sql: can_appeal
}
// IPBan contains the information association with a specific ip ban.
// table: DBPREFIXip_ban
type IPBan struct {
ID int
BoardID *int
BannedForPostID *int
CopyPostText template.HTML
RangeStart string
RangeEnd string
IssuedAt time.Time
ID int // sql: id
BoardID *int // sql: board_id
BannedForPostID *int // sql: banned_for_post_id
CopyPostText template.HTML // sql: copy_post_text
RangeStart string // sql: range_start
RangeEnd string // sql: range_end
IssuedAt time.Time // sql: issued_at
IPBanBase
}
// Deprecated: Use the RangeStart and RangeEnd fields or gcutil.GetIPRangeSubnet.
// IP was previously a field in the IPBan struct before range bans were
// implemented. This is here as a fallback for templates
func (ipb *IPBan) IP() string {
func (ipb *IPBan) IP() (string, error) {
if ipb.RangeStart == ipb.RangeEnd {
return ipb.RangeStart
return ipb.RangeStart, nil
}
inet, err := gcutil.GetIPRangeSubnet(ipb.RangeStart, ipb.RangeEnd)
if err != nil {
return "?"
return "", err
}
return inet.String()
return inet.String(), nil
}
// IsBanned returns true if the given IP is banned
func (ipb *IPBan) IsBanned(ipStr string) (bool, error) {
ipn, err := gcutil.GetIPRangeSubnet(ipb.RangeStart, ipb.RangeEnd)
if err != nil {

View file

@ -16,12 +16,13 @@ import (
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/posting"
"github.com/gochan-org/gochan/pkg/server"
"github.com/gochan-org/gochan/pkg/server/serverutil"
"github.com/rs/zerolog"
)
var (
ErrInsufficientPermission = errors.New("insufficient account permission")
ErrInsufficientPermission = server.NewServerError("insufficient account permission", http.StatusForbidden)
)
type uploadInfo struct {

View file

@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"github.com/Eggbertx/go-forms"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
@ -127,10 +128,10 @@ type formMode int
func (fmv formMode) String() string {
switch fmv {
case updateOwnPasswordForm:
return "Update Password"
case updateUserForm:
return "Update User"
case changePasswordForm:
return "Change Password"
case changeRankForm:
return "Change User Rank"
case newUserForm:
return "Add New User"
}
@ -139,19 +140,79 @@ func (fmv formMode) String() string {
const (
noForm formMode = iota
updateOwnPasswordForm
updateUserForm
changePasswordForm
changeRankForm
newUserForm
)
type staffForm struct {
Do string `form:"do"`
ChangePasswordForUser string `form:"changepass" method:"GET"`
ChangeRankForUser string `form:"changerank" method:"GET"`
Username string `form:"username"`
Password string `form:"password" method:"POST"`
PasswordConfirm string `form:"passwordconfirm" method:"POST"`
Rank int `form:"rank" method:"POST"`
}
func (s *staffForm) validate(staff *gcsql.Staff, warnEv *zerolog.Event) (formMode, error) {
if s.Do == "add" || (s.Do == "changepass" && s.Username != staff.Username) || s.Do == "changerank" || s.Do == "del" {
if staff.Rank < 3 {
warnEv.Caller().
Str("username", s.Username).
Str("do", s.Do).
Msg("non-admin tried to modify someone else's account or create a new account")
return noForm, ErrInsufficientPermission
}
}
if (s.Do == "del" || s.Do == "add") && s.Username == "" {
warnEv.Caller().Str("do", s.Do).Msg("Missing username field")
return noForm, errors.New("missing username field")
}
if s.Do == "add" && s.Password == "" {
warnEv.Caller().Str("do", s.Do).Msg("Missing password field")
return noForm, errors.New("missing password field")
}
if s.Do == "add" && s.Password != s.PasswordConfirm {
warnEv.Caller().Str("do", s.Do).Err(ErrPasswordsDoNotMatch).Send()
return noForm, ErrPasswordsDoNotMatch
}
if s.Do != "" && s.Do != "add" && s.Do != "changepass" && s.Do != "changerank" && s.Do != "del" {
warnEv.Caller().Str("do", s.Do).Msg("Invalid form action")
return noForm, errors.New("invalid form action")
}
if s.ChangePasswordForUser != "" {
if s.ChangePasswordForUser != staff.Username && staff.Rank < 3 {
warnEv.Caller().Str("username", s.Username).Msg("non-admin tried to change a password")
return noForm, ErrInsufficientPermission
}
return changePasswordForm, nil
}
if s.ChangeRankForUser != "" {
if staff.Rank < 3 {
warnEv.Caller().Str("username", s.Username).Msg("non-admin tried to change a rank")
return noForm, ErrInsufficientPermission
}
return changeRankForm, nil
}
if staff.Rank >= 3 {
return newUserForm, nil
}
return noForm, nil
}
func staffCallback(writer http.ResponseWriter, request *http.Request, staff *gcsql.Staff, wantsJSON bool, infoEv *zerolog.Event, errEv *zerolog.Event) (output any, err error) {
var allStaff []gcsql.Staff
allStaff, err = getAllStaffNopass(true)
if err != nil {
errEv.Err(err).Caller().Msg("Failed getting staff list")
return nil, errors.New("unable to get staff list")
}
if wantsJSON {
allStaff, err = getAllStaffNopass(true)
if err != nil {
errEv.Err(err).Caller().Msg("Failed getting staff list")
return nil, errors.New("unable to get staff list")
}
return allStaff, nil
}
@ -161,151 +222,105 @@ func staffCallback(writer http.ResponseWriter, request *http.Request, staff *gcs
Str("staff", staff.Username)
defer warnEv.Discard()
do := request.PostFormValue("do")
updateUsername := request.FormValue("update")
username := request.PostFormValue("username")
password := request.PostFormValue("password")
passwordConfirm := request.FormValue("passwordconfirm")
if (do == "add" || do == "update") && password != passwordConfirm {
return "", ErrPasswordsDoNotMatch
var form staffForm
var numErr *strconv.NumError
err = forms.FillStructFromForm(request, &form)
if errors.As(err, &numErr) {
errEv.Err(err).Caller().
Str("value", numErr.Num).
Msg("Error parsing form value")
writer.WriteHeader(http.StatusBadRequest)
return "", err
} else if err != nil {
errEv.Err(err).Caller().
Str("form", "staffForm").
Msg("Error filling form struct")
return "", err
}
if username != "" {
gcutil.LogStr("username", username, infoEv, errEv, warnEv)
formMode, err := form.validate(staff, warnEv)
if errors.Is(err, ErrInsufficientPermission) {
writer.WriteHeader(http.StatusForbidden)
return "", err
}
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return "", err
}
rankStr := request.PostFormValue("rank")
var rank int
if rankStr != "" {
if staff.Rank < 3 {
writer.WriteHeader(http.StatusUnauthorized)
warnEv.Caller().Str("username", username).Msg("non-admin tried to modify a staff account's rank")
return "", ErrInsufficientPermission
}
if rank, err = strconv.Atoi(rankStr); err != nil {
if form.Username != "" {
gcutil.LogStr("username", form.Username, infoEv, errEv, warnEv)
}
updateStaff := &gcsql.Staff{
Username: form.Username,
Rank: form.Rank,
}
switch formMode {
case changePasswordForm:
updateStaff, err = gcsql.GetStaffByUsername(form.ChangePasswordForUser, true)
if err != nil {
errEv.Err(err).Caller().
Str("rank", rankStr).Send()
Str("username", form.ChangePasswordForUser).
Msg("Error getting staff account")
return "", err
}
if rank < 0 || rank > 3 {
errEv.Caller().Int("rank", rank).Send()
return "", errors.New("invalid rank")
case changeRankForm:
updateStaff, err = gcsql.GetStaffByUsername(form.ChangeRankForUser, true)
if err != nil {
errEv.Err(err).Caller().
Str("username", form.ChangeRankForUser).
Msg("Error getting staff account")
return "", err
}
case newUserForm:
updateStaff.Username = form.Username
}
var formMode = noForm
if staff.Rank == 3 {
if updateUsername == "" {
formMode = newUserForm
} else {
formMode = updateUserForm
switch form.Do {
case "add":
if updateStaff, err = gcsql.NewStaff(form.Username, form.Password, form.Rank); err != nil {
errEv.Err(err).Caller().
Str("username", form.Username).
Msg("Error creating new staff account")
return "", fmt.Errorf("unable to create new staff account: %w", err)
}
} else {
if updateUsername == staff.Username {
formMode = updateOwnPasswordForm
} else if updateUsername != "" {
// user is a moderator or janitor and is trying to update someone else's account
writer.WriteHeader(http.StatusUnauthorized)
return nil, ErrInsufficientPermission
infoEv.Str("userRank", updateStaff.RankTitle()).Msg("New staff account created")
case "changepass":
if err = updateStaff.UpdatePassword(form.Password); err != nil {
errEv.Err(err).Caller().Msg("Error updating password")
return "", errors.New("unable to change staff account password")
}
infoEv.Msg("Password updated")
case "changerank":
if err = updateStaff.UpdateRank(form.Rank); err != nil {
errEv.Err(err).Caller().Msg("Error updating rank")
return "", errors.New("unable to change staff account rank")
}
infoEv.
Int("rank", updateStaff.Rank).
Str("rankTitle", updateStaff.RankTitle()).
Msg("Staff account rank updated")
case "del":
if err = updateStaff.ClearSessions(); err != nil {
errEv.Err(err).Caller().
Str("username", form.Username).
Msg("Unable to clear user login sessions")
return "", errors.New("unable to clear user login sessions")
}
if err = updateStaff.SetActive(false); err != nil {
errEv.Err(err).Caller().
Str("username", form.Username).
Msg("Unable to deactivate user")
return "", errors.New("unable to deactivate user")
}
infoEv.Str("userRank", updateStaff.RankTitle()).Msg("Account deactivated")
}
data := map[string]any{
"updateUsername": updateUsername,
"currentStaff": staff,
"formMode": formMode,
"updateRank": -1,
}
if updateUsername != "" && staff.Rank == AdminPerms {
var found bool
for _, user := range allStaff {
if user.Username == updateUsername {
data["updateRank"] = user.Rank
found = true
break
}
}
gcutil.LogStr("updateUsername", updateUsername, infoEv, errEv, warnEv)
if !found {
writer.WriteHeader(http.StatusBadRequest)
warnEv.Err(gcsql.ErrUnrecognizedUsername).Caller().Send()
return "", gcsql.ErrUnrecognizedUsername
}
}
if do == "add" {
if staff.Rank < 3 {
writer.WriteHeader(http.StatusUnauthorized)
warnEv.Caller().Str("username", username).Msg("non-admin tried to create a new account")
return "", ErrInsufficientPermission
}
var newStaff *gcsql.Staff
if newStaff, err = gcsql.NewStaff(username, password, rank); err != nil {
errEv.Err(err).Caller().
Str("userRank", newStaff.RankTitle()).
Msg("Error creating new staff account")
return "", fmt.Errorf("unable to create new staff account %q by %q: %s",
username, staff.Username, err.Error())
}
infoEv.Str("userRank", newStaff.RankTitle()).Msg("New staff account created")
} else if do == "update" || do == "del" {
if updateUsername == "" {
warnEv.Caller().Str("do", do).Msg("Missing username field")
return nil, errors.New("missing username field")
}
if (do == "update" && staff.Rank < AdminPerms && updateUsername != staff.Username) || (do == "del" && staff.Rank < AdminPerms) {
// user is not an admin and is trying to update someone else's account (rank change already checked)
writer.WriteHeader(http.StatusUnauthorized)
warnEv.Err(ErrInsufficientPermission).Send()
return nil, ErrInsufficientPermission
}
var user *gcsql.Staff
if user, err = gcsql.GetStaffByUsername(updateUsername, true); err != nil {
errEv.Err(err).Caller().Bool("onlyActive", true).Msg("Unable to get staff by username")
return nil, err
}
gcutil.LogStr("userRank", user.RankTitle(), infoEv, errEv, warnEv)
if do == "update" {
if password != "" {
if err = user.UpdatePassword(password); err != nil {
writer.WriteHeader(http.StatusInternalServerError)
errEv.Err(err).Caller().
Msg("Error updating password")
return "", errors.New("unable to update staff account password")
}
infoEv.Msg("Password updated")
} else if rank > 0 {
if err = user.UpdateRank(rank); err != nil {
writer.WriteHeader(http.StatusInternalServerError)
errEv.Err(err).Caller().
Msg("Error updating rank")
return "", errors.New("unable to update staff account rank")
}
infoEv.
Int("rank", user.Rank).
Str("rankTitle", user.RankTitle()).
Msg("Staff account rank updated")
}
data["formMode"] = newUserForm
data["updateUsername"] = ""
data["updateRank"] = -1
} else {
// deactivate account
if err = user.ClearSessions(); err != nil {
errEv.Err(err).Caller().
Msg("Unable to clear user login sessions")
return nil, errors.New("unable to clear user login sessions")
}
if err = user.SetActive(false); err != nil {
errEv.Err(err).Caller().
Msg("Unable to deactivate user")
return nil, errors.New("unable to deactivate user")
}
infoEv.Msg("Account deactivated")
}
"username": updateStaff.Username,
"rank": updateStaff.Rank,
"currentStaff": staff,
"formMode": formMode,
}
data["allstaff"], err = getAllStaffNopass(true)
@ -314,13 +329,13 @@ func staffCallback(writer http.ResponseWriter, request *http.Request, staff *gcs
return nil, errors.New("unable to get staff list")
}
staffBuffer := bytes.NewBufferString("")
if err = serverutil.MinifyTemplate(gctemplates.ManageStaff, data, staffBuffer, "text/html"); err != nil {
buffer := bytes.NewBufferString("")
if err = serverutil.MinifyTemplate(gctemplates.ManageStaff, data, buffer, "text/html"); err != nil {
errEv.Err(err).Str("template", "manage_staff.html").Send()
writer.WriteHeader(http.StatusInternalServerError)
return "", errors.New("unable to execute staff management page template")
}
return staffBuffer.String(), nil
return buffer.String(), nil
}
func registerJanitorPages() {

View file

@ -3,6 +3,7 @@ package manage
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"runtime/debug"
@ -111,7 +112,12 @@ func setupManageFunction(action *Action) bunrouter.HandlerFunc {
}
}
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
var serverError *server.ServerError
if errors.As(err, &serverError) {
writer.WriteHeader(serverError.StatusCode)
} else {
writer.WriteHeader(http.StatusInternalServerError)
}
serveError(writer, "actionerror", action.ID, err.Error(), wantsJSON || (action.JSONoutput == AlwaysJSON))
return
}

441
pkg/manage/manage_test.go Normal file
View file

@ -0,0 +1,441 @@
package manage
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"maps"
"github.com/DATA-DOG/go-sqlmock"
"github.com/PuerkitoBio/goquery"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/gcutil/testutil"
"github.com/stretchr/testify/assert"
)
const (
loginQueryRE = `SELECT\s*id,\s*username,\s*password_checksum,\s*global_rank,\s*added_on,\s*last_login,\s*is_active\s*FROM staff WHERE username = \? AND is_active = TRUE`
insertSessionRE = `INSERT INTO sessions \(staff_id,data,expires\) VALUES\(\?,\?,\?\)`
updateStaffLoginRE = `UPDATE staff SET last_login = CURRENT_TIMESTAMP WHERE id = \?`
)
var (
genericStaffList = []gcsql.Staff{
{Username: "admin", Rank: 3},
{Username: "mod", Rank: 2},
{Username: "janitor", Rank: 1},
}
loginTestCases = []manageCallbackTestCase{
{
desc: "GET login",
path: "/manage/login",
method: "GET",
expectStatus: http.StatusOK,
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
if !assert.NotNil(t, output) {
t.FailNow()
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(output.(string)))
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, 1, doc.Find("input[name=username]").Length())
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
assert.Equal(t, 1, doc.Find("input[value=Login]").Length())
},
}, {
desc: "POST login",
method: "POST",
path: "/manage/login",
header: http.Header{
"Referer": []string{"http://localhost/manage/login"},
},
form: url.Values{
"username": {"admin"},
"password": {"password"},
},
expectStatus: http.StatusFound,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
expectedSum := gcutil.BcryptSum("password")
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(1, "admin", expectedSum, 1, time.Now(), time.Now(), true),
)
mock.ExpectBegin()
mock.ExpectPrepare(insertSessionRE).ExpectExec().WithArgs(1, sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectPrepare(updateStaffLoginRE).ExpectExec().WithArgs(1).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
assert.Nil(t, output) // redirect, output is nil
},
}, {
desc: "POST login with invalid credentials",
method: "POST",
path: "/manage/login",
header: http.Header{
"Referer": []string{"http://localhost/manage/login"},
},
form: url.Values{
"username": {"admin"},
"password": {"wrongpassword"},
},
expectStatus: http.StatusUnauthorized,
expectError: true,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
notExpectedSum := gcutil.BcryptSum("password")
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(1, "admin", notExpectedSum, 1, time.Now(), time.Now(), true),
)
},
},
}
staffTestCases = []manageCallbackTestCase{
{
desc: "View staff list as admin",
method: "GET",
path: "/manage/staff",
staff: &gcsql.Staff{Rank: 3, Username: "admin"},
expectStatus: http.StatusOK,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
getStaffMockHelper(t, mock)
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, newUserForm)
},
},
{
desc: "View staff list as mod",
method: "GET",
path: "/manage/staff",
staff: &gcsql.Staff{Username: "mod", Rank: 2},
expectStatus: http.StatusOK,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
getStaffMockHelper(t, mock)
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
validateStaffOutput(t, &gcsql.Staff{Username: "mod", Rank: 2}, output, noForm)
},
},
{
desc: "View change rank form as admin",
method: "GET",
path: "/manage/staff?changerank=admin",
staff: &gcsql.Staff{Username: "admin", Rank: 3},
expectStatus: http.StatusOK,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
ExpectQuery().WithArgs("admin").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(1, "admin", gcutil.BcryptSum("password"), 3, time.Now(), time.Now(), true),
)
getStaffMockHelper(t, mock)
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, changeRankForm)
},
},
{
desc: "View change password form as admin",
method: "GET",
path: "/manage/staff?changepass=admin",
staff: &gcsql.Staff{Username: "admin", Rank: 3},
expectStatus: http.StatusOK,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
ExpectQuery().WithArgs("admin").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(1, "admin", gcutil.BcryptSum("password"), 3, time.Now(), time.Now(), true),
)
getStaffMockHelper(t, mock)
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, changePasswordForm)
},
},
{
desc: "View change password form as mod for self",
method: "GET",
path: "/manage/staff?changepass=mod",
staff: &gcsql.Staff{Username: "mod", Rank: 2},
expectStatus: http.StatusOK,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
ExpectQuery().WithArgs("mod").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(2, "mod", gcutil.BcryptSum("password"), 2, time.Now(), time.Now(), true),
)
getStaffMockHelper(t, mock)
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
validateStaffOutput(t, &gcsql.Staff{Username: "mod", Rank: 2}, output, changePasswordForm)
},
},
{
desc: "View change password form as mod for another account",
method: "GET",
path: "/manage/staff?changepass=janitor",
staff: &gcsql.Staff{Username: "mod", Rank: 2},
expectStatus: http.StatusForbidden,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
ExpectQuery().WithArgs("janitor").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(3, "janitor", gcutil.BcryptSum("password"), 1, time.Now(), time.Now(), true),
)
getStaffMockHelper(t, mock)
},
expectError: true,
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, err error) {
assert.Equal(t, http.StatusForbidden, writer.Code)
assert.ErrorIs(t, err, ErrInsufficientPermission)
assert.Empty(t, output)
},
},
{
desc: "View change change rank form as mod",
method: "GET",
path: "/manage/staff?changerank=mod",
staff: &gcsql.Staff{Username: "mod", Rank: 2},
expectStatus: http.StatusForbidden,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(`SELECT id, username, password_checksum, global_rank, added_on, last_login, is_active FROM staff WHERE username = \? AND is_active = TRUE`).
ExpectQuery().WithArgs("mod").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(2, "mod", gcutil.BcryptSum("password"), 2, time.Now(), time.Now(), true),
)
getStaffMockHelper(t, mock)
},
expectError: true,
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, err error) {
assert.Equal(t, http.StatusForbidden, writer.Code)
assert.ErrorIs(t, err, ErrInsufficientPermission)
assert.Empty(t, output)
},
},
{
desc: "Create new user as admin",
method: "POST",
path: "/manage/staff",
staff: &gcsql.Staff{Username: "admin", Rank: 3},
expectStatus: http.StatusOK,
form: url.Values{
"do": {"add"},
"username": {"newuser"},
"password": {"newpassword"},
"passwordconfirm": {"newpassword"},
"rank": {"1"},
},
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(`INSERT INTO staff \(username, password_checksum, global_rank\) VALUES\(\?,\?,\?\)`).ExpectExec().
WithArgs("newuser", sqlmock.AnyArg(), 1).
WillReturnResult(sqlmock.NewResult(1, 1))
getStaffMockHelper(t, mock,
gcsql.Staff{Username: "admin", Rank: 3},
gcsql.Staff{Username: "mod", Rank: 2},
gcsql.Staff{Username: "janitor", Rank: 1},
gcsql.Staff{Username: "newuser", Rank: 1})
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder, _ error) {
expectedStaff := append(genericStaffList, gcsql.Staff{Username: "newuser", Rank: 1})
validateStaffOutput(t, &gcsql.Staff{Username: "admin", Rank: 3}, output, newUserForm, expectedStaff...)
},
},
}
)
func getStaffMockHelper(t *testing.T, mock sqlmock.Sqlmock, expectedStaff ...gcsql.Staff) {
t.Helper()
if len(expectedStaff) == 0 {
expectedStaff = genericStaffList
}
rows := sqlmock.NewRows([]string{"id", "username", "global_rank", "added_on", "last_login", "is_active"})
for _, staff := range expectedStaff {
rows.AddRow(1, staff.Username, staff.Rank, time.Now(), time.Now(), true)
}
mock.ExpectPrepare(`SELECT\s*id,\s*username,\s*global_rank,\s*added_on,\s*last_login,\s*is_active\s*FROM staff WHERE is_active`).
ExpectQuery().WillReturnRows(rows)
}
func validateStaffOutput(t *testing.T, staff *gcsql.Staff, output any, expectedFormMode formMode, expectedStaffList ...gcsql.Staff) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(output.(string)))
if !assert.NoError(t, err) {
t.FailNow()
}
if len(expectedStaffList) == 0 {
expectedStaffList = append(expectedStaffList, genericStaffList...)
}
staffRows := doc.Find("table.stafflist tr")
assert.Equal(t, len(expectedStaffList)+1, staffRows.Length())
staffRows.Each(func(i int, s *goquery.Selection) {
if i == 0 {
return
}
if i > len(expectedStaffList) {
assert.Fail(t, "More staff rows than expected")
return
}
expectedStaff := expectedStaffList[i-1]
assert.Equal(t, expectedStaff.Username, s.Find("td").Eq(0).Text())
assert.Equal(t, expectedStaff.RankTitle(), s.Find("td").Eq(1).Text())
if staff.Rank == 3 && expectedStaff.Username == staff.Username {
assert.Equal(t, "Change Password | Change Rank", s.Find("td").Eq(3).Text())
} else if staff.Rank == 3 {
assert.Equal(t, "Change Password | Change Rank | Delete", s.Find("td").Eq(3).Text())
} else if staff.Rank < 3 && expectedStaff.Username == staff.Username {
assert.Equal(t, "Change Password", s.Find("td").Eq(3).Text())
} else {
assert.Equal(t, "", s.Find("td").Eq(3).Text())
}
})
hidden := doc.Find("input[type=hidden]")
switch expectedFormMode {
case newUserForm:
assert.Equal(t, "Add New User", doc.Find("h2").Text())
assert.Equal(t, 1, doc.Find("input[name=username]").Length())
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
assert.Equal(t, 1, doc.Find("input[name=passwordconfirm]").Length())
assert.Equal(t, 1, doc.Find("select[name=rank]").Length())
assert.Equal(t, 1, doc.Find("input[value='Create User']").Length())
assert.Equal(t, 0, doc.Find("input[value=Cancel]").Length())
assert.Equal(t, "add", hidden.Filter("[name=do]").AttrOr("value", ""))
case changePasswordForm:
assert.Equal(t, "Change Password", doc.Find("h2").Text())
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
assert.Equal(t, 1, doc.Find("input[name=passwordconfirm]").Length())
assert.Equal(t, 1, doc.Find("input[value='Update User']").Length())
assert.Equal(t, 1, doc.Find("input[value=Cancel]").Length())
assert.Equal(t, "changepass", hidden.Filter("[name=do]").AttrOr("value", ""))
assert.Equal(t, staff.Username, hidden.Filter("[name=username]").AttrOr("value", ""))
case changeRankForm:
assert.Equal(t, "Change User Rank", doc.Find("h2").Text())
assert.Equal(t, 1, doc.Find("input[value='Update User']").Length())
assert.Equal(t, 1, doc.Find("input[value=Cancel]").Length())
hidden := doc.Find("input[type=hidden]")
assert.Equal(t, "changerank", hidden.Filter("[name=do]").AttrOr("value", ""))
assert.Equal(t, staff.Username, hidden.Filter("[name=username]").AttrOr("value", ""))
case noForm:
assert.Equal(t, 0, doc.Find("h2").Length())
form := doc.Find("form[action='/manage/staff']")
assert.Equal(t, 0, form.Length())
}
}
// manageCallbackTestCase is a generic test case struct for testing the callback functions for /manage/{action}
type manageCallbackTestCase struct {
desc string
path string
staff *gcsql.Staff
method string
header http.Header
form url.Values
wantsJSON bool
expectError bool
expectStatus int
prepareMock func(t *testing.T, mock sqlmock.Sqlmock)
validateOutput func(t *testing.T, output any, writer *httptest.ResponseRecorder, err error)
}
func (tc *manageCallbackTestCase) runTest(t *testing.T, manageCallbackFunc CallbackFunction) {
infoEv := gcutil.LogInfo()
errEv := gcutil.LogError(nil)
db, mock, err := sqlmock.New()
if !assert.NoError(t, err) {
t.FailNow()
}
defer db.Close()
if !assert.NoError(t, gcsql.SetTestingDB("mysql", "gochan", "", db)) {
t.FailNow()
}
request, err := http.NewRequest(tc.method, "http://localhost"+tc.path, nil)
if !assert.NoError(t, err) {
t.FailNow()
}
if tc.staff == nil {
tc.staff = &gcsql.Staff{}
}
maps.Copy(request.Header, tc.header)
if tc.method == "POST" {
request.PostForm = tc.form
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
request.Form = tc.form
}
if tc.prepareMock != nil {
tc.prepareMock(t, mock)
}
writer := httptest.NewRecorder()
output, err := manageCallbackFunc(writer, request, tc.staff, tc.wantsJSON, infoEv, errEv)
assert.Equal(t, tc.expectStatus, writer.Code)
if tc.expectError {
assert.Error(t, err)
if tc.validateOutput != nil {
tc.validateOutput(t, output, writer, err)
}
} else {
if !assert.NoError(t, err) {
t.FailNow()
}
if !assert.NoError(t, mock.ExpectationsWereMet()) {
t.FailNow()
}
if tc.validateOutput == nil {
t.Fatal("validateOutput is nil")
}
tc.validateOutput(t, output, writer, err)
}
}
func setupManageTestSuite(t *testing.T) {
config.InitConfig()
_, err := testutil.GoToGochanRoot(t)
if !assert.NoError(t, err) {
t.FailNow()
}
systemCriticalConfig := config.GetSystemCriticalConfig()
systemCriticalConfig.TemplateDir = "templates"
systemCriticalConfig.SiteHost = "localhost"
config.SetSystemCriticalConfig(systemCriticalConfig)
gctemplates.InitTemplates()
}
func TestLoginCallback(t *testing.T) {
setupManageTestSuite(t)
for _, tc := range loginTestCases {
t.Run(tc.desc, func(t *testing.T) {
tc.runTest(t, loginCallback)
})
}
}
func TestStaffCallback(t *testing.T) {
setupManageTestSuite(t)
for _, tc := range staffTestCases {
t.Run(tc.desc, func(t *testing.T) {
tc.runTest(t, staffCallback)
})
}
}

View file

@ -1,180 +0,0 @@
package manage
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"maps"
"github.com/DATA-DOG/go-sqlmock"
"github.com/PuerkitoBio/goquery"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/gcutil/testutil"
"github.com/stretchr/testify/assert"
)
const (
loginQueryRE = `SELECT\s*id,\s*username,\s*password_checksum,\s*global_rank,\s*added_on,\s*last_login,\s*is_active\s*FROM staff WHERE username = \? AND is_active = TRUE`
insertSessionRE = `INSERT INTO sessions \(staff_id,data,expires\) VALUES\(\?,\?,\?\)`
updateStaffLoginRE = `UPDATE staff SET last_login = CURRENT_TIMESTAMP WHERE id = \?`
)
var (
loginTestCases = []manageCallbackTestCase{
{
desc: "GET login",
path: "/manage/login",
method: "GET",
expectStatus: http.StatusOK,
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder) {
if !assert.NotNil(t, output) {
t.FailNow()
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(output.(string)))
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, 1, doc.Find("input[name=username]").Length())
assert.Equal(t, 1, doc.Find("input[name=password]").Length())
assert.Equal(t, 1, doc.Find("input[value=Login]").Length())
},
}, {
desc: "POST login",
method: "POST",
path: "/manage/login",
header: http.Header{
"Referer": []string{"http://localhost/manage/login"},
},
form: url.Values{
"username": {"admin"},
"password": {"password"},
},
expectStatus: http.StatusFound,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(1, "admin", "$2a$10$EdXlrHd/vKQo9COSpxRdgOpjzEQ7As5mW4N5P4R4KrqaI8j3jO2PW", 1, time.Now(), time.Now(), true),
)
mock.ExpectBegin()
mock.ExpectPrepare(insertSessionRE).ExpectExec().WithArgs(1, sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectPrepare(updateStaffLoginRE).ExpectExec().WithArgs(1).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
},
validateOutput: func(t *testing.T, output any, writer *httptest.ResponseRecorder) {
assert.Nil(t, output) // redirect, output is nil
},
}, {
desc: "POST login with invalid credentials",
method: "POST",
path: "/manage/login",
header: http.Header{
"Referer": []string{"http://localhost/manage/login"},
},
form: url.Values{
"username": {"admin"},
"password": {"wrongpassword"},
},
expectStatus: http.StatusUnauthorized,
expectError: true,
prepareMock: func(t *testing.T, mock sqlmock.Sqlmock) {
mock.ExpectPrepare(loginQueryRE).ExpectQuery().WithArgs("admin").WillReturnRows(
sqlmock.NewRows([]string{"id", "username", "password_checksum", "global_rank", "added_on", "last_login", "is_active"}).
AddRow(1, "admin", "$2a$10$EdXlrHd/vKQo9COSpxRdgOpjzEQ7As5mW4N5P4R4KrqaI8j3jO2PW", 1, time.Now(), time.Now(), true),
)
},
},
}
)
// manageCallbackTestCase is a generic test case struct for testing the callback functions for /manage/{action}
type manageCallbackTestCase struct {
desc string
// writer *httptest.ResponseRecorder
path string
staff *gcsql.Staff
method string
header http.Header
form url.Values
wantsJSON bool
expectError bool
expectStatus int
prepareMock func(t *testing.T, mock sqlmock.Sqlmock)
validateOutput func(t *testing.T, output any, writer *httptest.ResponseRecorder)
}
func TestLoginCallback(t *testing.T) {
config.InitConfig()
_, err := testutil.GoToGochanRoot(t)
if !assert.NoError(t, err) {
t.FailNow()
}
systemCriticalConfig := config.GetSystemCriticalConfig()
systemCriticalConfig.TemplateDir = "templates"
systemCriticalConfig.SiteHost = "localhost"
config.SetSystemCriticalConfig(systemCriticalConfig)
gctemplates.InitTemplates()
infoEv := gcutil.LogInfo()
errEv := gcutil.LogError(nil)
for _, tc := range loginTestCases {
t.Run(tc.desc, func(t *testing.T) {
db, mock, err := sqlmock.New()
if !assert.NoError(t, err) {
t.FailNow()
}
defer db.Close()
if !assert.NoError(t, gcsql.SetTestingDB("mysql", "gochan", "", db)) {
t.FailNow()
}
defer assert.NoError(t, mock.ExpectationsWereMet())
request, err := http.NewRequest(tc.method, "http://localhost"+tc.path, nil)
if !assert.NoError(t, err) {
t.FailNow()
}
if tc.staff == nil {
tc.staff = &gcsql.Staff{}
}
maps.Copy(request.Header, tc.header)
if tc.method == "POST" {
request.PostForm = tc.form
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
request.Form = tc.form
}
if tc.prepareMock != nil {
tc.prepareMock(t, mock)
}
writer := httptest.NewRecorder()
output, err := loginCallback(writer, request, tc.staff, tc.wantsJSON, infoEv, errEv)
assert.Equal(t, tc.expectStatus, writer.Code)
if tc.expectError {
assert.Error(t, err)
if tc.validateOutput != nil {
tc.validateOutput(t, output, writer)
}
} else {
assert.NoError(t, err)
if tc.validateOutput == nil {
t.Fatal("validateOutput is nil")
}
tc.validateOutput(t, output, writer)
}
})
}
}

View file

@ -16,24 +16,24 @@ var (
router *bunrouter.Router
)
type serverError struct {
err any
statusCode int
type ServerError struct {
Err any
StatusCode int
}
func (e *serverError) Error() string {
return fmt.Sprint(e.err)
func (e *ServerError) Error() string {
return fmt.Sprint(e.Err)
}
func (e *serverError) Unwrap() error {
if err, ok := e.err.(error); ok {
func (e *ServerError) Unwrap() error {
if err, ok := e.Err.(error); ok {
return err
}
return nil
}
func NewServerError(message any, statusCode int) error {
return &serverError{err: message, statusCode: statusCode}
return &ServerError{Err: message, StatusCode: statusCode}
}
// ServeJSON serves data as a JSON string
@ -45,8 +45,8 @@ func ServeJSON(writer http.ResponseWriter, data map[string]any) {
// ServeErrorPage shows a general error page if something goes wrong
func ServeErrorPage(writer http.ResponseWriter, err any) {
if se, ok := err.(*serverError); ok {
writer.WriteHeader(se.statusCode)
if se, ok := err.(*ServerError); ok {
writer.WriteHeader(se.StatusCode)
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
serverutil.MinifyTemplate(gctemplates.ErrorPage, map[string]any{
@ -68,8 +68,8 @@ func ServeError(writer http.ResponseWriter, err any, wantsJSON bool, data map[st
servedMap = make(map[string]any)
}
servedMap["error"] = err
if se, ok := err.(*serverError); ok {
writer.WriteHeader(se.statusCode)
if se, ok := err.(*ServerError); ok {
writer.WriteHeader(se.StatusCode)
}
ServeJSON(writer, servedMap)
} else {

View file

@ -1,57 +1,68 @@
{{$isAdmin := (eq .currentStaff.Rank 3) -}}
<table class="mgmt-table stafflist">
<tr><th>Username</th><th>Rank</th><th>Added on</th><th>Action</th></tr>
{{range $s, $staff := $.allstaff -}}
<tr>
<td>{{$staff.Username}}</td>
<td>{{$staff.RankTitle}}</td>
<td>{{formatTimestamp $staff.AddedOn}}</td>
<td>
{{if or $isAdmin (eq $staff.Username $.currentStaff.Username) -}}
<a href="{{webPath `/manage/staff`}}?update={{$staff.Username}}">Update</a>
{{end}}{{if and $isAdmin (not (eq $staff.Username $.currentStaff.Username)) -}}
<a
href="{{webPath `/manage/staff`}}?do=del&username={{$staff.Username}}"
title="Delete {{$staff.Username}}"
onclick="return confirm('Are you sure you want to delete the staff account for \'{{$staff.Username}}\'?')"
style="color:red;">Delete</a>
{{- end}}
</td>
</tr>
{{end}}
</table>
{{- define "rankRow" -}}
<tr><th>{{if eq $.formType 3}}New {{end}}Rank</th><td>
<select id="rank" name="rank">
<option value="-1"{{if eq $.rank 0}}selected{{end}} disabled>Select one</option>
<option value="3"{{if eq $.rank 3}}selected{{end}}>Admin</option>
<option value="2"{{if eq $.rank 2}}selected{{end}}>Moderator</option>
<option value="1"{{if eq $.rank 1}}selected{{end}}>Janitor</option>
</select></td></tr>{{end -}}
{{- define "passwordRows" -}}
<tr><th>Password:</th><td><input name="password" type="password" autocomplete="new-password" /></td></tr>
<tr><th>Confirm password:</th><td><input id="passwordconfirm" name="passwordconfirm" type="password"/></td></tr>
{{- end -}}
<table class="mgmt-table stafflist">
<tr><th>Username</th><th>Rank</th><th>Added on</th><th>Action</th></tr>
{{range $s, $staff := $.allstaff -}}
<tr>
<td>{{$staff.Username}}</td>
<td>{{$staff.RankTitle}}</td>
<td>{{formatTimestamp $staff.AddedOn}}</td>
<td>
{{- if or $isAdmin (eq $staff.Username $.currentStaff.Username) -}}
<a href="{{webPath `/manage/staff`}}?changepass={{$staff.Username}}">Change Password</a>
{{- end}}{{if $isAdmin}} | <a
href="{{webPath `/manage/staff`}}?changerank={{$staff.Username}}">Change Rank</a>
{{- end}}{{if and $isAdmin (not (eq $staff.Username $.currentStaff.Username))}} | <a
href="{{webPath `/manage/staff`}}?do=del&username={{$staff.Username}}"
title="Delete {{$staff.Username}}"
onclick="return confirm('Are you sure you want to delete the staff account for \'{{$staff.Username}}\'?')"
style="color:red;">Delete</a>
{{- end -}}
</td>
</tr>
{{- end -}}
</table>
{{- if gt $.formMode 0 -}}
<hr />
<h2>{{$.formMode}}</h2>
{{- if eq $.formMode 2 -}}
<p>If the password fields are left blank, only the rank will be updated</p>
{{- end -}}
<form action="{{webPath `/manage/staff`}}" method="POST" autocomplete="off">
{{if lt $.formMode 3}}
<input type="hidden" name="update" value="{{.updateUsername}}">
<input type="hidden" name="do" value="update" />
{{else}}
<input type="hidden" name="do" value="add" />
{{end}}
<table>
<tr><td>Username:</td><td><input name="username" type="text" autocomplete="new-password" value="{{if $isAdmin}}{{.updateUsername}}{{else}}{{.currentStaff.Username}}{{end}}" {{if lt $.formMode 3}}disabled{{end}}/></td></tr>
<tr><td>Password:</td><td><input name="password" type="password" autocomplete="new-password" /></td></tr>
<tr><td>Confirm password:</td><td><input id="passwordconfirm" name="passwordconfirm" type="password"/></td></tr>
{{if gt $.formMode 1 -}}
<tr><td>Rank:</td><td><select id="rank" name="rank">
<option value="-1"{{if eq $.updateRank -1}}selected{{end}} disabled>Select one</option>
<option value="3"{{if eq $.updateRank 3}}selected{{end}}>Admin</option>
<option value="2"{{if eq $.updateRank 2}}selected{{end}}>Moderator</option>
<option value="1"{{if eq $.updateRank 1}}selected{{end}}>Janitor</option>
</select></td></tr>
{{end -}}
<tr><td>
<input type="submit" value="{{if eq $.formMode 3}}Create{{else}}Update{{end}} User" />
{{- if lt $.formMode 3 -}}
<input type="hidden" name="username" value="{{.username}}" />
{{- end -}}
<table>
<tr><th>Username</th><td>{{if lt $.formMode 3}}{{.username}}{{else}}<input type="text" name="username" value="{{.username}}"/>{{end}}</td></tr>
{{- if eq $.formMode 1 -}}
{{/* Change Password */}}
<input type="hidden" name="do" value="changepass" />
{{- template "passwordRows" . -}}
{{- else if eq $.formMode 2 -}}
{{/* Change Rank */}}
<input type="hidden" name="do" value="changerank" />
{{template "rankRow" .}}
{{- else if eq $.formMode 3 -}}
{{/* Add Staff */}}
<input type="hidden" name="do" value="add" />
{{- template "passwordRows" . -}}
{{- template "rankRow" . -}}
{{- end -}}
<tr><td><input type="submit" value="{{if eq $.formMode 3}}Create{{else}}Update{{end}} User" />
{{- if lt $.formMode 3 -}}
<input type="button" name="docancel" value="Cancel" onclick="window.location = {{webPath `./manage/staff`}}; return false"/>
{{- end -}}
</td></tr>
</table>
</td></tr>
</table>
</form>
{{end}}
{{end}}