feat(users): implement comprehensive user listing and control
This commit is contained in:
@@ -8,17 +8,31 @@ import (
|
||||
const AdminTagName = "admin"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Disabled bool `json:"disabled"`
|
||||
AdminNote string `json:"admin_note,omitempty"`
|
||||
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
||||
PermOverrides *UserPermOverrides `json:"perm_overrides,omitempty"`
|
||||
}
|
||||
|
||||
type UserPermOverrides struct {
|
||||
UploadAllowed *bool `json:"upload_allowed,omitempty"`
|
||||
ManageOwnBoxes *bool `json:"manage_own_boxes,omitempty"`
|
||||
ZipDownloadAllowed *bool `json:"zip_download_allowed,omitempty"`
|
||||
OneTimeDownloadAllowed *bool `json:"one_time_download_allowed,omitempty"`
|
||||
RenewableAllowed *bool `json:"renewable_allowed,omitempty"`
|
||||
AllowPasswordProtected *bool `json:"allow_password_protected,omitempty"`
|
||||
RenewOnAccess *bool `json:"renew_on_access,omitempty"`
|
||||
RenewOnDownload *bool `json:"renew_on_download,omitempty"`
|
||||
AllowOwnerBoxEditing *bool `json:"allow_owner_box_editing,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
@@ -42,6 +56,7 @@ type TagPermissions struct {
|
||||
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
|
||||
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
|
||||
AdminAccess bool `json:"admin_access"`
|
||||
AdminUsersView bool `json:"admin_users_view"`
|
||||
AdminUsersManage bool `json:"admin_users_manage"`
|
||||
AdminSettingsManage bool `json:"admin_settings_manage"`
|
||||
AdminBoxesView bool `json:"admin_boxes_view"`
|
||||
@@ -67,6 +82,7 @@ type EffectivePermissions struct {
|
||||
RenewOnAccessSeconds int64
|
||||
RenewOnDownloadSeconds int64
|
||||
AdminAccess bool
|
||||
AdminUsersView bool
|
||||
AdminUsersManage bool
|
||||
AdminSettingsManage bool
|
||||
AdminBoxesView bool
|
||||
@@ -152,3 +168,75 @@ type BoxRecordPage struct {
|
||||
NextPage int
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
type UserFilters struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type UserPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type UserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Status string
|
||||
Role string
|
||||
TagIDs []string
|
||||
Tags string
|
||||
Plan string
|
||||
PolicySummary string
|
||||
BoxCount int
|
||||
APIKeyCount int
|
||||
CreatedAt string
|
||||
LastSeen string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
IsInvite bool
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Rows []UserRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
TotalPages int
|
||||
Stats UserPageStats
|
||||
}
|
||||
|
||||
type UserPageStats struct {
|
||||
TotalUsers int
|
||||
ActiveUsers int
|
||||
PendingInvites int
|
||||
DisabledUsers int
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
Username string
|
||||
Email string
|
||||
Password string
|
||||
Mode string
|
||||
Role string
|
||||
Plan string
|
||||
AdminNote string
|
||||
SendSetup bool
|
||||
ForceChange bool
|
||||
}
|
||||
|
||||
type CreateUserResult struct {
|
||||
User User
|
||||
InviteToken string
|
||||
InviteLink string
|
||||
IsInvite bool
|
||||
PasswordSet string
|
||||
InviteNotSent bool
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
|
||||
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
|
||||
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
|
||||
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
|
||||
perms.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
|
||||
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
|
||||
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
|
||||
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
|
||||
@@ -50,6 +51,21 @@ func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) Effective
|
||||
perms.MaxExpirySeconds = *user.MaxExpirySeconds
|
||||
}
|
||||
|
||||
if o := user.PermOverrides; o != nil {
|
||||
if o.UploadAllowed != nil {
|
||||
perms.UploadAllowed = *o.UploadAllowed
|
||||
}
|
||||
if o.ZipDownloadAllowed != nil {
|
||||
perms.ZipDownloadAllowed = *o.ZipDownloadAllowed
|
||||
}
|
||||
if o.OneTimeDownloadAllowed != nil {
|
||||
perms.OneTimeDownloadAllowed = *o.OneTimeDownloadAllowed
|
||||
}
|
||||
if o.RenewableAllowed != nil {
|
||||
perms.RenewableAllowed = *o.RenewableAllowed
|
||||
}
|
||||
}
|
||||
|
||||
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
|
||||
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
|
||||
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -291,6 +292,295 @@ func (store *Store) ListUsers() ([]User, error) {
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (store *Store) ListUsersPaginated(filters UserFilters, pageReq UserPageRequest) (UserPage, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
|
||||
tags, err := store.ListTags()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
tagMap := make(map[string]Tag, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagMap[tag.ID] = tag
|
||||
}
|
||||
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
filtered := make([]User, 0, len(users))
|
||||
for _, user := range users {
|
||||
if query != "" {
|
||||
if !strings.Contains(strings.ToLower(user.Username), query) &&
|
||||
!strings.Contains(strings.ToLower(user.Email), query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch filters.Status {
|
||||
case "active":
|
||||
if user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "disabled":
|
||||
if !user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "pending":
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filters.Role != "" && filters.Role != "all" {
|
||||
match := false
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, filters.Role) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
|
||||
switch filters.Sort {
|
||||
case "createdDesc":
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||
})
|
||||
case "username":
|
||||
fallthrough
|
||||
default:
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return strings.ToLower(filtered[i].Username) < strings.ToLower(filtered[j].Username)
|
||||
})
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
pageSize := pageReq.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 12
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
page := pageReq.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageUsers := filtered[start:end]
|
||||
|
||||
stats := UserPageStats{TotalUsers: len(users)}
|
||||
for _, user := range users {
|
||||
if strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
stats.PendingInvites++
|
||||
} else if user.Disabled {
|
||||
stats.DisabledUsers++
|
||||
} else {
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]UserRow, len(pageUsers))
|
||||
for i, user := range pageUsers {
|
||||
role := ""
|
||||
tagNames := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok {
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
if tag.Permissions.AdminAccess && role == "" {
|
||||
role = tag.Name
|
||||
} else if role == "" {
|
||||
role = tag.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
plan := "standard"
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, "admin") {
|
||||
plan = "unlimited"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isInvite := strings.HasPrefix(user.PasswordHash, "invite/")
|
||||
status := userStatus(user.Disabled)
|
||||
if isInvite {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
rows[i] = UserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Status: status,
|
||||
Role: role,
|
||||
TagIDs: user.TagIDs,
|
||||
Tags: strings.Join(tagNames, ", "),
|
||||
Plan: plan,
|
||||
PolicySummary: "system default",
|
||||
BoxCount: 0,
|
||||
APIKeyCount: 0,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
LastSeen: "-",
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: false,
|
||||
IsInvite: isInvite,
|
||||
}
|
||||
}
|
||||
|
||||
return UserPage{
|
||||
Rows: rows,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
TotalPages: totalPages,
|
||||
Stats: stats,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userStatus(disabled bool) string {
|
||||
if disabled {
|
||||
return "disabled"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func (store *Store) BulkSetUsersDisabled(ids []string, disabled bool) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, id := range ids {
|
||||
var user User
|
||||
if err := getJSON(txn, userKey(id), &user); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
user.Disabled = disabled
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
if err := putJSON(txn, userKey(id), user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) RevokeUserSessions(userID string) error {
|
||||
tokens, err := store.sessionTokensForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, token := range tokens {
|
||||
if err := txn.Delete(sessionKey(token)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) BulkRevokeUserSessions(ids []string) error {
|
||||
for _, id := range ids {
|
||||
if err := store.RevokeUserSessions(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) sessionTokensForUser(userID string) ([]string, error) {
|
||||
tokens := []string{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("session/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var session Session
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &session)
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
if session.UserID == userID {
|
||||
tokens = append(tokens, session.Token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func (store *Store) CountAdminUsers(adminTagID string) (int, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTagID {
|
||||
count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateUserWithoutPassword(username string, email string, tagIDs []string) (User, error) {
|
||||
hash, err := helpers.RandomHexID(32)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: "invite/" + hash,
|
||||
TagIDs: uniqueStrings(tagIDs),
|
||||
Disabled: true,
|
||||
}
|
||||
if err := store.CreateUser(&user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
|
||||
var id string
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
|
||||
@@ -22,6 +22,7 @@ func AdminPermissions() TagPermissions {
|
||||
ZipDownloadAllowed: true,
|
||||
RenewableAllowed: true,
|
||||
AdminAccess: true,
|
||||
AdminUsersView: true,
|
||||
AdminUsersManage: true,
|
||||
AdminSettingsManage: true,
|
||||
AdminBoxesView: true,
|
||||
|
||||
@@ -48,6 +48,18 @@ func (app *App) registerAccountRoutes(router *gin.Engine) {
|
||||
protected.POST("/boxes/:id/password", app.handleAccountBoxPassword)
|
||||
protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove)
|
||||
protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete)
|
||||
protected.GET("/users", app.handleAccountUsers)
|
||||
protected.POST("/users", app.handleAccountUsersPost)
|
||||
protected.POST("/users/bulk/disable", app.handleAccountUsersBulkDisable)
|
||||
protected.POST("/users/bulk/enable", app.handleAccountUsersBulkEnable)
|
||||
protected.POST("/users/bulk/revoke-sessions", app.handleAccountUsersBulkRevokeSessions)
|
||||
protected.POST("/users/:id/invite/resend", app.handleAccountUsersResendInvite)
|
||||
protected.GET("/users/:id", app.handleAccountUserEdit)
|
||||
protected.POST("/users/:id", app.handleAccountUserEditPost)
|
||||
protected.POST("/users/:id/disable", app.handleAccountUserDisable)
|
||||
protected.POST("/users/:id/enable", app.handleAccountUserEnable)
|
||||
protected.POST("/users/:id/password/reset", app.handleAccountUserPasswordReset)
|
||||
protected.POST("/users/:id/sessions/revoke", app.handleAccountUserRevokeSessions)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLogin(ctx *gin.Context) {
|
||||
|
||||
@@ -243,3 +243,616 @@ func findResponseCookie(response *httptest.ResponseRecorder, name string) *http.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUsersPagePermissionDeniedForNoPerms(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("viewer", "viewer@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "Permission denied") {
|
||||
t.Fatal("expected permission denied message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPageLoadsForAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"WarpBox Users", "Create or Invite", "Total users"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected users page body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPageListFilters(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
_, err := app.store.CreateUserWithPassword("beta", "beta@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users?q=beta", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "beta") {
|
||||
t.Fatal("expected filtered list to contain beta")
|
||||
}
|
||||
if !strings.Contains(body, "1 matching user(s)") {
|
||||
t.Fatalf("expected 1 matching user for beta filter, got body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreation(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("action", "create")
|
||||
form.Set("mode", "create")
|
||||
form.Set("username", "newuser")
|
||||
form.Set("email", "new@example.test")
|
||||
form.Set("password", "password123")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect after create, got %d", response.Code)
|
||||
}
|
||||
|
||||
created, ok, err := app.store.GetUserByUsername("newuser")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected newuser to exist")
|
||||
}
|
||||
if created.Disabled {
|
||||
t.Fatal("expected newuser to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInviteCreation(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("action", "create")
|
||||
form.Set("mode", "invite")
|
||||
form.Set("username", "invited")
|
||||
form.Set("email", "invited@example.test")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect after invite, got %d", response.Code)
|
||||
}
|
||||
|
||||
created, ok, err := app.store.GetUserByUsername("invited")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected invited user to exist")
|
||||
}
|
||||
if !created.Disabled {
|
||||
t.Fatal("expected invited user to be disabled")
|
||||
}
|
||||
if !strings.HasPrefix(created.PasswordHash, "invite/") {
|
||||
t.Fatal("expected invited user to have invite prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDisableRejectsSelf(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", user.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "cannot disable yourself") && !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected self-disable rejection, got location %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDisableProtectsFinalAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok || adminTag.ID == "" {
|
||||
t.Fatal("expected admin tag")
|
||||
}
|
||||
second, err := app.store.CreateUserWithPassword("admin2", "admin2@example.test", "secret", []string{adminTag.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
|
||||
// Admin tries to disable the other admin (not self): should work since self remains.
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", second.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected success redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "user(s) disabled") {
|
||||
t.Fatalf("expected success message, got %q", location)
|
||||
}
|
||||
|
||||
// Verify admin2 is disabled, admin1 still active
|
||||
disabledUser, ok, _ := app.store.GetUserByUsername("admin2")
|
||||
if !ok || !disabledUser.Disabled {
|
||||
t.Fatal("expected admin2 to be disabled")
|
||||
}
|
||||
adminUser, ok, _ := app.store.GetUserByUsername("admin")
|
||||
if !ok || adminUser.Disabled {
|
||||
t.Fatal("expected admin to remain active")
|
||||
}
|
||||
|
||||
// Now try to disable the only remaining admin (self): should be rejected
|
||||
form2 := url.Values{}
|
||||
form2.Set("csrf_token", session.CSRFToken)
|
||||
form2.Set("selected_ids", user.ID)
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form2.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
resp2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp2, req2)
|
||||
|
||||
if resp2.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect for self-disable rejection, got %d", resp2.Code)
|
||||
}
|
||||
loc2 := resp2.Header().Get("Location")
|
||||
if !strings.Contains(loc2, "cannot disable yourself") {
|
||||
t.Fatalf("expected self-disable rejection, got %q", loc2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPagePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
regular, err := app.store.CreateUserWithPassword("viewer2", "viewer2@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(regular.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users/"+regular.ID, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPageLoadsForAdmin(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("edittarget", "edittarget@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users/"+target.ID, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"edittarget", "Access rights", "Limits", "Setting overrides", "Resolved policy"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditProfileUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("origname", "orig@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", "newname")
|
||||
form.Set("email", "new@example.test")
|
||||
form.Set("admin_note", "test note")
|
||||
form.Set("state", "active")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok {
|
||||
t.Fatal("user not found after update")
|
||||
}
|
||||
if updated.Username != "newname" {
|
||||
t.Fatalf("expected username newname, got %q", updated.Username)
|
||||
}
|
||||
if updated.AdminNote != "test note" {
|
||||
t.Fatalf("expected admin note, got %q", updated.AdminNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditAccessRightsUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("perm_target", "perm@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("upload_allowed", "1")
|
||||
form.Set("zip_download_allowed", "1")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok || updated.PermOverrides == nil {
|
||||
t.Fatal("expected perm overrides to be set")
|
||||
}
|
||||
if updated.PermOverrides.UploadAllowed == nil || !*updated.PermOverrides.UploadAllowed {
|
||||
t.Fatal("expected upload_allowed=true")
|
||||
}
|
||||
if updated.PermOverrides.ZipDownloadAllowed == nil || !*updated.PermOverrides.ZipDownloadAllowed {
|
||||
t.Fatal("expected zip_download_allowed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditLimitsUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("limits_target", "limits@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("max_file_size_bytes", "1073741824")
|
||||
form.Set("max_expiry_seconds", "86400")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok {
|
||||
t.Fatal("user not found")
|
||||
}
|
||||
if updated.MaxFileSizeBytes == nil || *updated.MaxFileSizeBytes != 1073741824 {
|
||||
t.Fatalf("expected max_file_size_bytes=1073741824, got %v", updated.MaxFileSizeBytes)
|
||||
}
|
||||
if updated.MaxExpirySeconds == nil || *updated.MaxExpirySeconds != 86400 {
|
||||
t.Fatalf("expected max_expiry_seconds=86400, got %v", updated.MaxExpirySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditInvalidLimitRejected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("badlimit", "badlimit@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("max_file_size_bytes", "notanumber")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error redirect, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditSelfDisableRejected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", admin.Username)
|
||||
form.Set("email", admin.Email)
|
||||
form.Set("state", "disabled")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error redirect, got %q", location)
|
||||
}
|
||||
|
||||
unchanged, _, _ := app.store.GetUser(admin.ID)
|
||||
if unchanged.Disabled {
|
||||
t.Fatal("admin should not have been disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditLastAdminProtected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
// try to remove admin tag from the only admin via is_admin=0 (unchecked)
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", admin.Username)
|
||||
form.Set("email", admin.Email)
|
||||
// is_admin NOT set → wantsAdmin=false → should be blocked
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error for last-admin removal, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPasswordReset(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("resetme", "resetme@example.test", "oldpass", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/password/reset", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "success=") {
|
||||
t.Fatalf("expected success redirect, got %q", location)
|
||||
}
|
||||
|
||||
updated, _, _ := app.store.GetUser(target.ID)
|
||||
if metastore.VerifyPassword(updated.PasswordHash, "oldpass") {
|
||||
t.Fatal("old password should no longer work after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditRevokeSessions(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("revokeme", "revokeme@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
targetSession, err := app.store.CreateSession(target.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
adminSession, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", adminSession.CSRFToken)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/sessions/revoke", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: adminSession.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
_, stillValid, _ := app.store.GetSession(targetSession.Token)
|
||||
if stillValid {
|
||||
t.Fatal("target session should have been revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkRevokeSessions(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
other, err := app.store.CreateUserWithPassword("other", "other@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
if _, err := app.store.CreateSession(other.ID, time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", other.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/revoke-sessions", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "Sessions revoked") {
|
||||
t.Fatalf("expected success message, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
557
lib/server/account_user_edit.go
Normal file
557
lib/server/account_user_edit.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type UserEditView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Target metastore.User
|
||||
Tags []metastore.Tag
|
||||
AdminTagID string
|
||||
IsAdmin bool
|
||||
IsPending bool
|
||||
Status string
|
||||
Perms metastore.EffectivePermissions
|
||||
PolicyJSON string
|
||||
CanManage bool
|
||||
IsSelf bool
|
||||
Error string
|
||||
Success string
|
||||
// precomputed display values
|
||||
TagNames string
|
||||
CreatedAtStr string
|
||||
UpdatedAtStr string
|
||||
MaxFileSizeStr string
|
||||
MaxBoxSizeStr string
|
||||
MaxExpiryStr string
|
||||
// precomputed perm override checkbox states
|
||||
Check map[string]bool
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEdit(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "")
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_user_edit.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEditPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// profile
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
adminNote := strings.TrimSpace(ctx.PostForm("admin_note"))
|
||||
if username == "" {
|
||||
redirectUserEdit(ctx, userID, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// state (cannot change pending via this field)
|
||||
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||
if !isPending {
|
||||
stateVal := ctx.PostForm("state")
|
||||
switch stateVal {
|
||||
case "disabled":
|
||||
if target.ID == actor.ID {
|
||||
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
if !target.Disabled {
|
||||
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||
return
|
||||
}
|
||||
}
|
||||
target.Disabled = true
|
||||
case "active":
|
||||
target.Disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// admin tag toggle
|
||||
adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not verify admin tag.", "")
|
||||
return
|
||||
}
|
||||
wantsAdmin := ctx.PostForm("is_admin") == "1"
|
||||
if adminTagOK {
|
||||
hasAdmin := containsString(target.TagIDs, adminTag.ID)
|
||||
if wantsAdmin && !hasAdmin {
|
||||
target.TagIDs = append(target.TagIDs, adminTag.ID)
|
||||
} else if !wantsAdmin && hasAdmin {
|
||||
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
target.TagIDs = removeString(target.TagIDs, adminTag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// per-user permission overrides
|
||||
target.PermOverrides = &metastore.UserPermOverrides{
|
||||
UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"),
|
||||
ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"),
|
||||
ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"),
|
||||
OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"),
|
||||
RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"),
|
||||
AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"),
|
||||
RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"),
|
||||
RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"),
|
||||
AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"),
|
||||
}
|
||||
|
||||
// limits
|
||||
if raw := ctx.PostForm("max_file_size_bytes"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxFileSizeBytes = v
|
||||
} else {
|
||||
target.MaxFileSizeBytes = nil
|
||||
}
|
||||
if raw := ctx.PostForm("max_box_size_bytes"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxBoxSizeBytes = v
|
||||
} else {
|
||||
target.MaxBoxSizeBytes = nil
|
||||
}
|
||||
if raw := ctx.PostForm("max_expiry_seconds"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxExpirySeconds = v
|
||||
} else {
|
||||
target.MaxExpirySeconds = nil
|
||||
}
|
||||
|
||||
target.Username = username
|
||||
target.Email = email
|
||||
target.AdminNote = adminNote
|
||||
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "User saved.")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserDisable(ctx *gin.Context) {
|
||||
app.handleAccountUserSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEnable(ctx *gin.Context) {
|
||||
app.handleAccountUserSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == actor.ID && disabled {
|
||||
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if disabled {
|
||||
if err := app.checkLastAdminDisable([]string{userID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
target.Disabled = disabled
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not update user.", "")
|
||||
return
|
||||
}
|
||||
action := "enabled"
|
||||
if disabled {
|
||||
action = "disabled"
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "User "+action+".")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
newPassword := randomPassword()
|
||||
hash, err := metastore.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not hash password.", "")
|
||||
return
|
||||
}
|
||||
target.PasswordHash = hash
|
||||
target.Disabled = false
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not reset password.", "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if err := app.store.RevokeUserSessions(userID); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "All sessions revoked.")
|
||||
}
|
||||
|
||||
func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) {
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
return UserEditView{}, err
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
adminTagID := ""
|
||||
for _, t := range tags {
|
||||
if t.Name == metastore.AdminTagName {
|
||||
adminTagID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin := containsString(target.TagIDs, adminTagID)
|
||||
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||
status := "active"
|
||||
if isPending {
|
||||
status = "pending"
|
||||
} else if target.Disabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
effectivePerms, _ := app.permissionsForUser(target)
|
||||
|
||||
policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides)
|
||||
|
||||
// tag names
|
||||
tagNames := make([]string, 0, len(target.TagIDs))
|
||||
for _, tagID := range target.TagIDs {
|
||||
for _, t := range tags {
|
||||
if t.ID == tagID {
|
||||
tagNames = append(tagNames, t.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// perm override checkboxes
|
||||
checks := map[string]bool{
|
||||
"upload_allowed": effectivePerms.UploadAllowed,
|
||||
"manage_own_boxes": false,
|
||||
"zip_download_allowed": effectivePerms.ZipDownloadAllowed,
|
||||
"one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed,
|
||||
"renewable_allowed": effectivePerms.RenewableAllowed,
|
||||
"allow_password_protected": false,
|
||||
"renew_on_access": false,
|
||||
"renew_on_download": false,
|
||||
"allow_owner_box_editing": false,
|
||||
}
|
||||
if o := target.PermOverrides; o != nil {
|
||||
if o.UploadAllowed != nil {
|
||||
checks["upload_allowed"] = *o.UploadAllowed
|
||||
}
|
||||
if o.ManageOwnBoxes != nil {
|
||||
checks["manage_own_boxes"] = *o.ManageOwnBoxes
|
||||
}
|
||||
if o.ZipDownloadAllowed != nil {
|
||||
checks["zip_download_allowed"] = *o.ZipDownloadAllowed
|
||||
}
|
||||
if o.OneTimeDownloadAllowed != nil {
|
||||
checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed
|
||||
}
|
||||
if o.RenewableAllowed != nil {
|
||||
checks["renewable_allowed"] = *o.RenewableAllowed
|
||||
}
|
||||
if o.AllowPasswordProtected != nil {
|
||||
checks["allow_password_protected"] = *o.AllowPasswordProtected
|
||||
}
|
||||
if o.RenewOnAccess != nil {
|
||||
checks["renew_on_access"] = *o.RenewOnAccess
|
||||
}
|
||||
if o.RenewOnDownload != nil {
|
||||
checks["renew_on_download"] = *o.RenewOnDownload
|
||||
}
|
||||
if o.AllowOwnerBoxEditing != nil {
|
||||
checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing
|
||||
}
|
||||
}
|
||||
|
||||
return UserEditView{
|
||||
PageTitle: "Edit User — " + target.Username,
|
||||
WindowTitle: "User Edit — " + target.Username,
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-user-edit.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Target: target,
|
||||
Tags: tags,
|
||||
AdminTagID: adminTagID,
|
||||
IsAdmin: isAdmin,
|
||||
IsPending: isPending,
|
||||
Status: status,
|
||||
Perms: effectivePerms,
|
||||
PolicyJSON: policyJSON,
|
||||
CanManage: canManage,
|
||||
IsSelf: actor.ID == target.ID,
|
||||
Error: errMsg,
|
||||
Success: successMsg,
|
||||
TagNames: strings.Join(tagNames, ", "),
|
||||
CreatedAtStr: formatAdminTime(target.CreatedAt),
|
||||
UpdatedAtStr: formatAdminTime(target.UpdatedAt),
|
||||
MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes),
|
||||
MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes),
|
||||
MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds),
|
||||
Check: checks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string {
|
||||
type permMap struct {
|
||||
BoxesCreate bool `json:"boxes.create"`
|
||||
ManageOwn bool `json:"boxes.manage_own"`
|
||||
RefreshOwn bool `json:"boxes.refresh_own"`
|
||||
DownloadsZip bool `json:"downloads.zip"`
|
||||
DownloadsOneTime bool `json:"downloads.one_time"`
|
||||
AdminAccess bool `json:"admin.access"`
|
||||
AdminUsers bool `json:"admin.users.manage"`
|
||||
AdminSettings bool `json:"admin.settings.manage"`
|
||||
}
|
||||
type limitsMap struct {
|
||||
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
|
||||
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
|
||||
MaxExpirySeconds int64 `json:"max_expiry_seconds"`
|
||||
}
|
||||
type overridesMap struct {
|
||||
AllowPassword bool `json:"allow_password_protected"`
|
||||
RenewOnAccess bool `json:"renew_on_access"`
|
||||
RenewOnDownload bool `json:"renew_on_download"`
|
||||
AllowOwnerEdit bool `json:"allow_owner_box_editing"`
|
||||
}
|
||||
type preview struct {
|
||||
User string `json:"user"`
|
||||
Status string `json:"status"`
|
||||
Permissions permMap `json:"permissions"`
|
||||
Limits limitsMap `json:"limits"`
|
||||
Overrides overridesMap `json:"overrides"`
|
||||
}
|
||||
|
||||
manageOwn := false
|
||||
allowPwd := false
|
||||
renewAccess := false
|
||||
renewDownload := false
|
||||
allowOwnerEdit := false
|
||||
if overrides != nil {
|
||||
if overrides.ManageOwnBoxes != nil {
|
||||
manageOwn = *overrides.ManageOwnBoxes
|
||||
}
|
||||
if overrides.AllowPasswordProtected != nil {
|
||||
allowPwd = *overrides.AllowPasswordProtected
|
||||
}
|
||||
if overrides.RenewOnAccess != nil {
|
||||
renewAccess = *overrides.RenewOnAccess
|
||||
}
|
||||
if overrides.RenewOnDownload != nil {
|
||||
renewDownload = *overrides.RenewOnDownload
|
||||
}
|
||||
if overrides.AllowOwnerBoxEditing != nil {
|
||||
allowOwnerEdit = *overrides.AllowOwnerBoxEditing
|
||||
}
|
||||
}
|
||||
|
||||
p := preview{
|
||||
User: username,
|
||||
Status: status,
|
||||
Permissions: permMap{
|
||||
BoxesCreate: perms.UploadAllowed,
|
||||
ManageOwn: manageOwn,
|
||||
RefreshOwn: perms.RenewableAllowed,
|
||||
DownloadsZip: perms.ZipDownloadAllowed,
|
||||
DownloadsOneTime: perms.OneTimeDownloadAllowed,
|
||||
AdminAccess: perms.AdminAccess,
|
||||
AdminUsers: perms.AdminUsersManage,
|
||||
AdminSettings: perms.AdminSettingsManage,
|
||||
},
|
||||
Limits: limitsMap{
|
||||
MaxFileSizeBytes: perms.MaxFileSizeBytes,
|
||||
MaxBoxSizeBytes: perms.MaxBoxSizeBytes,
|
||||
MaxExpirySeconds: perms.MaxExpirySeconds,
|
||||
},
|
||||
Overrides: overridesMap{
|
||||
AllowPassword: allowPwd,
|
||||
RenewOnAccess: renewAccess,
|
||||
RenewOnDownload: renewDownload,
|
||||
AllowOwnerEdit: allowOwnerEdit,
|
||||
},
|
||||
}
|
||||
data, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (app *App) checkLastAdminDisable(ids []string) error {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removing := 0
|
||||
for _, id := range ids {
|
||||
u, found, _ := app.store.GetUser(id)
|
||||
if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) {
|
||||
removing++
|
||||
}
|
||||
}
|
||||
if adminCount-removing < 1 {
|
||||
return fmt.Errorf("cannot remove the last active administrator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func int64PtrStr(v *int64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", *v)
|
||||
}
|
||||
|
||||
func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) {
|
||||
base := "/account/users/" + userID
|
||||
if errMsg != "" {
|
||||
ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg)
|
||||
} else if successMsg != "" {
|
||||
ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg)
|
||||
} else {
|
||||
ctx.Redirect(http.StatusSeeOther, base)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeString(slice []string, s string) []string {
|
||||
out := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if v != s {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
@@ -24,8 +24,12 @@ func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||
protected.Use(app.requireAdminSession)
|
||||
protected.POST("/logout", app.handleAdminLogout)
|
||||
protected.GET("/boxes", app.handleAdminBoxes)
|
||||
protected.GET("/users", app.handleAdminUsers)
|
||||
protected.POST("/users", app.handleAdminUsersPost)
|
||||
protected.GET("/users", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||
})
|
||||
protected.POST("/users", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||
})
|
||||
protected.GET("/tags", app.handleAdminTags)
|
||||
protected.POST("/tags", app.handleAdminTagsPost)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,112 +13,381 @@ import (
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminUserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Tags string
|
||||
CreatedAt string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
const defaultUserPageSize = 12
|
||||
|
||||
type UsersIndexView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters UserFiltersView
|
||||
Rows []metastore.UserRow
|
||||
Stats metastore.UserPageStats
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
CanManage bool
|
||||
Tags []metastore.Tag
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminUsers(ctx, "")
|
||||
type UserFiltersView struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
func (app *App) handleAccountUsers(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.PostForm("action") == "toggle_disabled" {
|
||||
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
app.renderAdminUsers(ctx, "User not found.")
|
||||
return
|
||||
}
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
||||
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
||||
return
|
||||
}
|
||||
}
|
||||
user.Disabled = !user.Disabled
|
||||
if err := app.store.UpdateUser(user); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
username := ctx.PostForm("username")
|
||||
email := ctx.PostForm("email")
|
||||
password := ctx.PostForm("password")
|
||||
tagIDs := ctx.PostFormArray("tag_ids")
|
||||
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
}
|
||||
filters := userFiltersFromRequest(ctx)
|
||||
pageReq := userPageFromRequest(ctx)
|
||||
|
||||
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||
users, err := app.store.ListUsers()
|
||||
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||
return
|
||||
}
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
|
||||
currentID := actor.ID
|
||||
for i := range userPage.Rows {
|
||||
if userPage.Rows[i].ID == currentID {
|
||||
userPage.Rows[i].IsCurrent = true
|
||||
}
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
view := UsersIndexView{
|
||||
PageTitle: "WarpBox Users",
|
||||
WindowTitle: "WarpBox Users",
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-users.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: UserFiltersView{
|
||||
Query: filters.Query,
|
||||
Status: filters.Status,
|
||||
Role: filters.Role,
|
||||
Sort: filters.Sort,
|
||||
PageSize: pageReq.PageSize,
|
||||
},
|
||||
Rows: userPage.Rows,
|
||||
Stats: userPage.Stats,
|
||||
Page: userPage.Page,
|
||||
PageSize: userPage.PageSize,
|
||||
Total: userPage.Total,
|
||||
TotalPages: userPage.TotalPages,
|
||||
HasPrev: userPage.HasPrev,
|
||||
HasNext: userPage.HasNext,
|
||||
CanManage: perms.AdminUsersManage,
|
||||
Tags: tags,
|
||||
Error: ctx.Query("error"),
|
||||
Success: ctx.Query("success"),
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "account_users.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
tagNames := make(map[string]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagNames[tag.ID] = tag.Name
|
||||
}
|
||||
sort.Slice(users, func(i int, j int) bool {
|
||||
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
||||
})
|
||||
|
||||
currentID := ""
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok {
|
||||
currentID = currentUser.ID
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.PostForm("action")
|
||||
switch action {
|
||||
case "create":
|
||||
app.handleAccountUsersCreate(ctx, actor)
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Unknown action", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersCreate(ctx *gin.Context, _ metastore.User) {
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
||||
role := strings.TrimSpace(ctx.PostForm("role"))
|
||||
|
||||
if username == "" {
|
||||
redirectAccountUsers(ctx, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
redirectAccountUsers(ctx, "Email is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
var tagIDs []string
|
||||
if role != "" && role != "all" {
|
||||
tag, ok, err := app.store.GetTagByName(role)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not look up role.", "")
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]adminUserRow, 0, len(users))
|
||||
for _, user := range users {
|
||||
names := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if name := tagNames[tagID]; name != "" {
|
||||
names = append(names, name)
|
||||
switch mode {
|
||||
case "invite":
|
||||
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
msg := fmt.Sprintf("Invite user created. Setup link: %s (Email delivery not yet implemented.)", inviteLink)
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
case "create":
|
||||
password := strings.TrimSpace(ctx.PostForm("password"))
|
||||
autoGen := false
|
||||
if password == "" {
|
||||
password = randomPassword()
|
||||
autoGen = true
|
||||
}
|
||||
user, err := app.store.CreateUserWithPassword(username, email, password, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("User %s created.", user.Username)
|
||||
if autoGen {
|
||||
msg = fmt.Sprintf("User %s created. Temporary password: %s", user.Username, password)
|
||||
}
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Select create or invite mode.", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkDisable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkEnable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if id == actor.ID {
|
||||
redirectAccountUsers(ctx, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if disabled {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
disableAdminCount := 0
|
||||
for _, id := range ids {
|
||||
user, ok, err := app.store.GetUser(id)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
if !user.Disabled {
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTag.ID {
|
||||
disableAdminCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, adminUserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Tags: strings.Join(names, ", "),
|
||||
CreatedAt: formatAdminTime(user.CreatedAt),
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: user.ID == currentID,
|
||||
})
|
||||
if adminCount-disableAdminCount < 1 {
|
||||
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||
"AdminSection": "users",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Users": rows,
|
||||
"Tags": tags,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not update users.", "")
|
||||
return
|
||||
}
|
||||
|
||||
action := "disabled"
|
||||
if !disabled {
|
||||
action = "enabled"
|
||||
}
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("%d user(s) %s.", len(ids), action))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.store.BulkRevokeUserSessions(ids); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Sessions revoked for %d user(s).", len(ids)))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersResendInvite(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == "" {
|
||||
redirectAccountUsers(ctx, "User ID is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
redirectAccountUsers(ctx, "This user is not a pending invite.", "")
|
||||
return
|
||||
}
|
||||
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Invite link: %s (Email delivery not yet implemented.)", inviteLink))
|
||||
}
|
||||
|
||||
func userFiltersFromRequest(ctx *gin.Context) metastore.UserFilters {
|
||||
return metastore.UserFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Status: strings.TrimSpace(ctx.Query("status")),
|
||||
Role: strings.TrimSpace(ctx.Query("role")),
|
||||
Sort: strings.TrimSpace(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func userPageFromRequest(ctx *gin.Context) metastore.UserPageRequest {
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(ctx.Query("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
pageSize := defaultUserPageSize
|
||||
if ps, err := strconv.Atoi(ctx.Query("page_size")); err == nil && ps >= 1 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
return metastore.UserPageRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSelectedIDs(ctx *gin.Context) []string {
|
||||
raw := ctx.PostForm("selected_ids")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func redirectAccountUsers(ctx *gin.Context, errorMsg string, successMsg string) {
|
||||
redirectURL := "/account/users"
|
||||
if errorMsg != "" {
|
||||
redirectURL = "/account/users?error=" + errorMsg
|
||||
} else if successMsg != "" {
|
||||
redirectURL = "/account/users?success=" + successMsg
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, redirectURL)
|
||||
}
|
||||
|
||||
func randomPassword() string {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
result := make([]byte, 16)
|
||||
for i := range result {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "changeme123"
|
||||
}
|
||||
result[i] = charset[n.Int64()]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user