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)
|
||||
}
|
||||
|
||||
@@ -1560,3 +1560,475 @@ textarea:disabled {
|
||||
bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Users page */
|
||||
.account-error-banner {
|
||||
padding: 8px 10px;
|
||||
color: #000;
|
||||
background: #ffcccc;
|
||||
border: 2px solid #800000;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.account-success-banner {
|
||||
padding: 8px 10px;
|
||||
color: #000;
|
||||
background: #ccffcc;
|
||||
border: 2px solid #008000;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.users-grid {
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.users-form-window .section-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.users-table-window {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-table-window .win98-titlebar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.users-filters-bar {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #808080;
|
||||
}
|
||||
|
||||
.users-filters-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) 130px 130px 130px 100px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.users-bulk-strip {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
background: #efefef;
|
||||
border-bottom: 1px solid #808080;
|
||||
}
|
||||
|
||||
.table-body-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.check-cell {
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.email-cell {
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
line-height: 11px;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actions-cell .tiny-button {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.users-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.users-filters-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.users-filters-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.users-bulk-strip {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.users-bulk-strip .small-action {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared form controls ─────────────────────────────────────────── */
|
||||
|
||||
.win98-input,
|
||||
.win98-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 2px 6px;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.win98-input:focus,
|
||||
.win98-select:focus {
|
||||
outline: 2px dotted #000078;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.small-action {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 72px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
color: #000000;
|
||||
background: var(--w98-gray);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
box-shadow: inset -1px -1px 0 #a0a0a0, inset 1px 1px 0 #e8e8e8;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.small-action:active:not(:disabled) {
|
||||
border-top-color: #808080;
|
||||
border-left-color: #808080;
|
||||
border-right-color: #ffffff;
|
||||
border-bottom-color: #ffffff;
|
||||
box-shadow: inset -1px -1px 0 #e8e8e8, inset 1px 1px 0 #a0a0a0;
|
||||
}
|
||||
|
||||
.small-action.is-primary {
|
||||
background: #000078;
|
||||
color: #ffffff;
|
||||
border-top-color: #4040c0;
|
||||
border-left-color: #4040c0;
|
||||
border-right-color: #000040;
|
||||
border-bottom-color: #000040;
|
||||
}
|
||||
|
||||
.small-action:disabled {
|
||||
color: #808080;
|
||||
text-shadow: 1px 1px 0 #ffffff;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.field-row label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
color: #444444;
|
||||
font-size: 11px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #c0c0c0;
|
||||
background: #efefef;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── User edit page ───────────────────────────────────────────────── */
|
||||
|
||||
.ue-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ue-column {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.ue-panel-body {
|
||||
padding: 10px;
|
||||
margin: 0 6px 6px;
|
||||
}
|
||||
|
||||
.ue-panel-sub {
|
||||
color: #555555;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.ue-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ue-field {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ue-field label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.ue-field-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ue-help {
|
||||
color: #444444;
|
||||
font-size: 11px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.ue-check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ue-check-grid-1col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ue-check-card {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px;
|
||||
background: #f4f4f4;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #c0c0c0;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ue-check-card input[type="checkbox"] {
|
||||
margin-top: 2px;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ue-check-copy {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ue-check-copy strong {
|
||||
font-size: 12px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.ue-check-copy span {
|
||||
color: #444444;
|
||||
font-size: 11px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.ue-info-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ue-info-item {
|
||||
display: grid;
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 5px 7px;
|
||||
background: #f5f5f5;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #c0c0c0;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.ue-info-item strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ue-info-item span {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ue-policy-pre {
|
||||
max-height: 260px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
color: #0a8f2f;
|
||||
background: #050505;
|
||||
border-top: 2px solid #808080;
|
||||
border-left: 2px solid #808080;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
font-family: 'MonoCraft', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ue-danger-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ue-danger-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ue-danger-btn {
|
||||
border-top-color: #ff8080;
|
||||
border-left-color: #ff8080;
|
||||
border-right-color: #400000;
|
||||
border-bottom-color: #400000;
|
||||
}
|
||||
|
||||
.ue-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
|
||||
.ue-footer-left,
|
||||
.ue-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.ue-content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ue-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ue-check-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ue-info-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
101
static/js/account-user-edit.js
Normal file
101
static/js/account-user-edit.js
Normal file
@@ -0,0 +1,101 @@
|
||||
(function () {
|
||||
const form = document.querySelector('[data-ue-form]');
|
||||
const dirtyIndicator = document.querySelector('[data-ue-dirty]');
|
||||
const menuItems = Array.from(document.querySelectorAll('.menu-item'));
|
||||
const toast = document.getElementById('account-toast');
|
||||
let dirty = false;
|
||||
let toastTimer = null;
|
||||
|
||||
function setDirty(next) {
|
||||
dirty = next;
|
||||
if (dirtyIndicator) {
|
||||
dirtyIndicator.textContent = dirty ? 'Unsaved changes' : 'No unsaved changes';
|
||||
}
|
||||
const chip = document.querySelector('[data-dirty-chip]');
|
||||
if (chip) {
|
||||
chip.textContent = dirty ? '● unsaved' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
if (!toast) return;
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast is-visible' + (type ? ' toast-' + type : '');
|
||||
window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(function () {
|
||||
toast.classList.remove('is-visible');
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
function closeMenus() {
|
||||
menuItems.forEach(function (item) { item.classList.remove('is-open'); });
|
||||
}
|
||||
|
||||
menuItems.forEach(function (item) {
|
||||
const btn = item.querySelector('.menu-button');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const open = item.classList.contains('is-open');
|
||||
closeMenus();
|
||||
if (!open) item.classList.add('is-open');
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', closeMenus);
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenus();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('change', function () { setDirty(true); });
|
||||
form.addEventListener('input', function () { setDirty(true); });
|
||||
form.addEventListener('submit', function () { setDirty(false); });
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-ue-command]').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
closeMenus();
|
||||
const cmd = el.getAttribute('data-ue-command');
|
||||
switch (cmd) {
|
||||
case 'save':
|
||||
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||||
break;
|
||||
case 'discard':
|
||||
if (dirty && form) {
|
||||
if (window.confirm('Discard unsaved changes?')) {
|
||||
setDirty(false);
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'reset-password': {
|
||||
const resetForm = document.querySelector('form[action*="/password/reset"]');
|
||||
if (resetForm && window.confirm('Reset this user\'s password? A temporary password will be generated and shown.')) {
|
||||
resetForm.submit();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
showToast('Action: ' + cmd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// sticky scroll shadow on taskbar
|
||||
const header = document.querySelector('.top-taskbar');
|
||||
if (header) {
|
||||
function updateScroll() {
|
||||
header.classList.toggle('is-scrolled', window.scrollY > 4);
|
||||
}
|
||||
updateScroll();
|
||||
window.addEventListener('scroll', updateScroll, { passive: true });
|
||||
}
|
||||
}());
|
||||
67
static/js/account-users.js
Normal file
67
static/js/account-users.js
Normal file
@@ -0,0 +1,67 @@
|
||||
(function () {
|
||||
const masterCheck = document.getElementById('master-check');
|
||||
const rowChecks = document.querySelectorAll('.row-check');
|
||||
const bulkForm = document.getElementById('users-bulk-form');
|
||||
const selectedIdsInput = document.getElementById('bulk-selected-ids');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const focusCreateBtn = document.querySelector('[data-users-action="focus-create"]');
|
||||
const selectVisibleBtns = document.querySelectorAll('[data-users-action="select-visible"]');
|
||||
|
||||
function updateSelected() {
|
||||
const checked = document.querySelectorAll('.row-check:checked');
|
||||
const ids = Array.from(checked).map(cb => cb.value);
|
||||
selectedIdsInput.value = ids.join(',');
|
||||
if (selectedCount) {
|
||||
selectedCount.textContent = ids.length + ' selected';
|
||||
}
|
||||
if (masterCheck) {
|
||||
const allRowChecks = document.querySelectorAll('.row-check');
|
||||
masterCheck.checked = allRowChecks.length > 0 && checked.length === allRowChecks.length;
|
||||
masterCheck.indeterminate = checked.length > 0 && checked.length < allRowChecks.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (masterCheck) {
|
||||
masterCheck.addEventListener('change', function () {
|
||||
document.querySelectorAll('.row-check').forEach(function (cb) {
|
||||
cb.checked = masterCheck.checked;
|
||||
});
|
||||
updateSelected();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
if (event.target.classList.contains('row-check')) {
|
||||
updateSelected();
|
||||
}
|
||||
});
|
||||
|
||||
selectVisibleBtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.row-check').forEach(function (cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
updateSelected();
|
||||
});
|
||||
});
|
||||
|
||||
if (focusCreateBtn) {
|
||||
focusCreateBtn.addEventListener('click', function () {
|
||||
var usernameInput = document.getElementById('users-username');
|
||||
if (usernameInput) {
|
||||
usernameInput.scrollIntoView({ behavior: 'smooth' });
|
||||
setTimeout(function () { usernameInput.focus(); }, 150);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSelected();
|
||||
})();
|
||||
|
||||
function setBulkAction(actionUrl) {
|
||||
var form = document.getElementById('users-bulk-form');
|
||||
if (form) {
|
||||
form.action = actionUrl;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
323
templates/account_user_edit.html
Normal file
323
templates/account_user_edit.html
Normal file
@@ -0,0 +1,323 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="user-edit-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<nav class="menu-bar" aria-label="User edit toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<button class="menu-action" type="button" data-ue-command="save"><span>💾</span><span>Save user</span><span class="shortcut">Ctrl+S</span></button>
|
||||
<button class="menu-action" type="button" data-ue-command="discard"><span>↩</span><span>Discard changes</span><span class="shortcut">Esc</span></button>
|
||||
{{ if .CanManage }}
|
||||
<div class="menu-separator"></div>
|
||||
{{ if .IsPending }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/invite/resend" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>✉</span><span>Send invite again</span><span></span></button>
|
||||
</form>
|
||||
{{ end }}
|
||||
<button class="menu-action" type="button" data-ue-command="reset-password"><span>🔑</span><span>Reset password</span><span></span></button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">User</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
{{ if .CanManage }}
|
||||
{{ if not .IsSelf }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/enable" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>✔</span><span>Enable user</span><span></span></button>
|
||||
</form>
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/disable" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>⛔</span><span>Disable user</span><span></span></button>
|
||||
</form>
|
||||
<div class="menu-separator"></div>
|
||||
{{ end }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>◌</span><span>Revoke all sessions</span><span></span></button>
|
||||
</form>
|
||||
{{ end }}
|
||||
<a class="menu-action" href="/account/users"><span>←</span><span>Back to users</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="account-body-content">
|
||||
{{ if .Error }}
|
||||
<div class="account-error-banner">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ if .Success }}
|
||||
<div class="account-success-banner">{{ .Success }}</div>
|
||||
{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="User summary">
|
||||
{{ if eq .Status "active" }}
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
{{ else if eq .Status "pending" }}
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
{{ else }}
|
||||
<article class="stat-card sunken-panel is-danger">
|
||||
{{ end }}
|
||||
<p class="stat-label">Status</p>
|
||||
<p class="stat-value">{{ .Status }}</p>
|
||||
<p class="stat-note">
|
||||
{{ if eq .Status "active" }}<span class="stat-note-pill">can sign in</span>
|
||||
{{ else if eq .Status "pending" }}<span class="stat-note-pill">invite not accepted</span>
|
||||
{{ else }}<span class="stat-note-pill">blocked</span>{{ end }}
|
||||
</p>
|
||||
</article>
|
||||
{{ if .IsAdmin }}
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
{{ else }}
|
||||
<article class="stat-card sunken-panel">
|
||||
{{ end }}
|
||||
<p class="stat-label">Role</p>
|
||||
<p class="stat-value">{{ if .IsAdmin }}admin{{ else }}user{{ end }}</p>
|
||||
<p class="stat-note">
|
||||
{{ if .TagNames }}<span class="stat-note-pill">{{ .TagNames }}</span>{{ else }}<span class="stat-note-pill">no tags</span>{{ end }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel">
|
||||
<p class="stat-label">Max file size</p>
|
||||
<p class="stat-value">{{ if .MaxFileSizeStr }}{{ .MaxFileSizeStr }}{{ else }}default{{ end }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">bytes</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel">
|
||||
<p class="stat-label">Max expiry</p>
|
||||
<p class="stat-value">{{ if .MaxExpiryStr }}{{ .MaxExpiryStr }}s{{ else }}default{{ end }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">seconds</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}" id="user-edit-form" data-ue-form>
|
||||
{{ template "account_csrf_field" . }}
|
||||
|
||||
<div class="ue-content-grid">
|
||||
<div class="ue-column">
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">A</span>
|
||||
<h2>Account <span class="ue-panel-sub">identity and basic state</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-form-grid">
|
||||
<div class="ue-field">
|
||||
<label for="ue-username">Username</label>
|
||||
<input class="win98-input" id="ue-username" name="username" type="text" value="{{ .Target.Username }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
|
||||
<span class="ue-help">Visible login name.</span>
|
||||
</div>
|
||||
<div class="ue-field">
|
||||
<label for="ue-email">Email</label>
|
||||
<input class="win98-input" id="ue-email" name="email" type="email" value="{{ .Target.Email }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
|
||||
<span class="ue-help">Account contact and invite destination.</span>
|
||||
</div>
|
||||
{{ if not .IsPending }}
|
||||
<div class="ue-field">
|
||||
<label for="ue-state">State</label>
|
||||
<select class="win98-select" id="ue-state" name="state" {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
|
||||
<option value="active" {{ if eq .Status "active" }}selected{{ end }}>Active</option>
|
||||
<option value="disabled" {{ if eq .Status "disabled" }}selected{{ end }}>Disabled</option>
|
||||
</select>
|
||||
<span class="ue-help">{{ if .IsSelf }}Cannot disable yourself.{{ else }}Account state.{{ end }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="ue-field">
|
||||
<label for="ue-admin-note">Admin note</label>
|
||||
<input class="win98-input" id="ue-admin-note" name="admin_note" type="text" value="{{ .Target.AdminNote }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Private note. Not shown to the user.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">R</span>
|
||||
<h2>Access rights <span class="ue-panel-sub">what this account can do</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-check-grid">
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="upload_allowed" value="1" {{ if index .Check "upload_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Create boxes</strong><span>Allow browser or API box creation.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="manage_own_boxes" value="1" {{ if index .Check "manage_own_boxes" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Manage own boxes</strong><span>Edit sharing, password, or expiry for owned boxes.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="renewable_allowed" value="1" {{ if index .Check "renewable_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Refresh own box expiry</strong><span>Permits time extension within limits.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="zip_download_allowed" value="1" {{ if index .Check "zip_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Use ZIP downloads</strong><span>Allow ZIP generation on this user's boxes.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="one_time_download_allowed" value="1" {{ if index .Check "one_time_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Use one-time boxes</strong><span>Permit one-time ZIP handoff boxes.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="is_admin" value="1" {{ if .IsAdmin }}checked{{ end }} {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Administrator</strong><span>Grants full admin area access. Last admin is protected.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">L</span>
|
||||
<h2>Limits <span class="ue-panel-sub">0 = unlimited, empty = system default</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-form-grid">
|
||||
<div class="ue-field">
|
||||
<label for="ue-max-file">Max file size (bytes)</label>
|
||||
<input class="win98-input" id="ue-max-file" name="max_file_size_bytes" type="number" min="0"
|
||||
value="{{ .MaxFileSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Per-file cap. Empty = system default.</span>
|
||||
</div>
|
||||
<div class="ue-field">
|
||||
<label for="ue-max-box">Max box size (bytes)</label>
|
||||
<input class="win98-input" id="ue-max-box" name="max_box_size_bytes" type="number" min="0"
|
||||
value="{{ .MaxBoxSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Total size per box. Empty = system default.</span>
|
||||
</div>
|
||||
<div class="ue-field ue-field-full">
|
||||
<label for="ue-max-expiry">Max box expiry (seconds)</label>
|
||||
<input class="win98-input" id="ue-max-expiry" name="max_expiry_seconds" type="number" min="0"
|
||||
value="{{ .MaxExpiryStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Maximum expiry when creating or editing a box. Empty = system default.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ue-column">
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">O</span>
|
||||
<h2>Setting overrides <span class="ue-panel-sub">account-specific behavior</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-check-grid ue-check-grid-1col">
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="allow_password_protected" value="1" {{ if index .Check "allow_password_protected" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow password-protected boxes</strong><span>Overrides system default for this account.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="renew_on_access" value="1" {{ if index .Check "renew_on_access" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow renew on access</strong><span>Only applies when the global feature is enabled.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="renew_on_download" value="1" {{ if index .Check "renew_on_download" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow renew on download</strong><span>Only applies when the global feature is enabled.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="allow_owner_box_editing" value="1" {{ if index .Check "allow_owner_box_editing" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow owner box editing</strong><span>Lets the user open the box edit page for owned boxes.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">P</span>
|
||||
<h2>Resolved policy <span class="ue-panel-sub">effective permissions after all overrides</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<pre class="ue-policy-pre">{{ .PolicyJSON }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">I</span>
|
||||
<h2>Account info <span class="ue-panel-sub">read-only details</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<ul class="ue-info-list">
|
||||
<li class="ue-info-item"><strong>User ID</strong><span>{{ .Target.ID }}</span></li>
|
||||
<li class="ue-info-item"><strong>Created</strong><span>{{ .CreatedAtStr }}</span></li>
|
||||
<li class="ue-info-item"><strong>Updated</strong><span>{{ .UpdatedAtStr }}</span></li>
|
||||
<li class="ue-info-item"><strong>Tags</strong><span>{{ if .TagNames }}{{ .TagNames }}{{ else }}none{{ end }}</span></li>
|
||||
<li class="ue-info-item"><strong>Password</strong><span>{{ if .IsPending }}pending invite{{ else }}set{{ end }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ if .CanManage }}
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">!</span>
|
||||
<h2>Danger zone</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-danger-row">
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/password/reset">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button ue-danger-btn" type="submit">Reset password</button>
|
||||
</form>
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit">Revoke sessions</button>
|
||||
</form>
|
||||
{{ if not .IsSelf }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/{{ if .Target.Disabled }}enable{{ else }}disable{{ end }}">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button ue-danger-btn" type="submit">{{ if .Target.Disabled }}Enable{{ else }}Disable{{ end }} user</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ue-footer">
|
||||
<div class="ue-footer-left">
|
||||
<span class="stat-note-pill" data-ue-dirty>No unsaved changes</span>
|
||||
<a class="stat-note-pill" href="/account/users">← Back to users</a>
|
||||
</div>
|
||||
<div class="ue-footer-right">
|
||||
{{ if .CanManage }}
|
||||
<button class="win98-button" type="button" data-ue-command="discard">Discard</button>
|
||||
<button class="win98-button" type="submit">Save user</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="User edit status">
|
||||
<span>editing: {{ .Target.Username }}</span>
|
||||
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||
<span>{{ .Status }}</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
257
templates/account_users.html
Normal file
257
templates/account_users.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="account-users-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<nav class="menu-bar" aria-label="Users toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/users"><span>R</span><span>Refresh list</span><span class="shortcut">F5</span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/users?status=active"><span>A</span><span>Show active</span><span></span></a>
|
||||
<a class="menu-action" href="/account/users?status=disabled"><span>D</span><span>Show disabled</span><span></span></a>
|
||||
<a class="menu-action" href="/account/users"><span>X</span><span>Clear filters</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="account-body-content">
|
||||
<section class="dashboard-hero raised-panel" aria-label="Users overview">
|
||||
<div class="hero-copy">
|
||||
<h2 id="account-users-title">WarpBox Users</h2>
|
||||
<p>Accounts, invites, and access. Search, filter, and manage users with safe bulk actions.</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="small-action is-primary" type="button" data-users-action="focus-create">Create / Invite</button>
|
||||
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
|
||||
<button class="small-action" type="button" onclick="location.href='/account/users'">Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ if .Error }}
|
||||
<div class="account-error-banner">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ if .Success }}
|
||||
<div class="account-success-banner">{{ .Success }}</div>
|
||||
{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="User statistics">
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Total users</p>
|
||||
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">all</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Active</p>
|
||||
<p class="stat-value">{{ .Stats.ActiveUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">enabled</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Pending invites</p>
|
||||
<p class="stat-value">{{ .Stats.PendingInvites }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">awaiting setup</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-danger">
|
||||
<p class="stat-label">Disabled</p>
|
||||
<p class="stat-value">{{ .Stats.DisabledUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">blocked</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid users-grid" aria-label="Users panel and form">
|
||||
<aside class="win98-window section-window users-form-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">+</span>
|
||||
<h2>Create or Invite</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<form class="form-grid" method="post" action="/account/users">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<input type="hidden" name="action" value="create">
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-mode">Mode</label>
|
||||
<select class="win98-select" name="mode" id="users-mode">
|
||||
<option value="create">Create local user</option>
|
||||
<option value="invite">Send invite</option>
|
||||
</select>
|
||||
<div class="field-help">Invite creates a disabled account with a setup link. Create makes an active user immediately.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-username">Username</label>
|
||||
<input class="win98-input" name="username" id="users-username" required placeholder="username" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-email">Email</label>
|
||||
<input class="win98-input" name="email" id="users-email" type="email" required placeholder="user@example.test" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-password">Password</label>
|
||||
<input class="win98-input" name="password" id="users-password" type="password" autocomplete="new-password" placeholder="Leave empty for auto-generated">
|
||||
<div class="field-help">If empty, a temporary password will be generated. Never prefill passwords.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-role">Role</label>
|
||||
<select class="win98-select" name="role" id="users-role">
|
||||
<option value="all">No tag (default)</option>
|
||||
{{ range .Tags }}
|
||||
<option value="{{ .Name }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<div class="field-help">Assign an initial role tag. Permissions are resolved from tag settings.</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="small-action" type="reset">Clear</button>
|
||||
<button class="small-action is-primary" type="submit">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="win98-window section-window span-2 users-table-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">U</span>
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="users-filters-bar">
|
||||
<form class="users-filters-form" method="get" action="/account/users" id="users-filters-form">
|
||||
<input class="win98-input" name="q" value="{{ .Filters.Query }}" placeholder="Search username or email">
|
||||
<select class="win98-select" name="status" onchange="this.form.submit()">
|
||||
<option value="" {{ if eq .Filters.Status "" }}selected{{ end }}>all statuses</option>
|
||||
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>active</option>
|
||||
<option value="disabled" {{ if eq .Filters.Status "disabled" }}selected{{ end }}>disabled</option>
|
||||
</select>
|
||||
<select class="win98-select" name="role" onchange="this.form.submit()">
|
||||
<option value="" {{ if eq .Filters.Role "" }}selected{{ end }}>all roles</option>
|
||||
{{ range .Tags }}
|
||||
<option value="{{ .Name }}" {{ if eq $.Filters.Role .Name }}selected{{ end }}>{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<select class="win98-select" name="sort" onchange="this.form.submit()">
|
||||
<option value="username" {{ if eq .Filters.Sort "username" }}selected{{ end }}>sort username</option>
|
||||
<option value="createdDesc" {{ if eq .Filters.Sort "createdDesc" }}selected{{ end }}>newest first</option>
|
||||
</select>
|
||||
<select class="win98-select" name="page_size" onchange="this.form.submit()">
|
||||
<option value="12" {{ if eq .Filters.PageSize 12 }}selected{{ end }}>12 rows</option>
|
||||
<option value="20" {{ if eq .Filters.PageSize 20 }}selected{{ end }}>20 rows</option>
|
||||
<option value="50" {{ if eq .Filters.PageSize 50 }}selected{{ end }}>50 rows</option>
|
||||
</select>
|
||||
<noscript><button class="small-action" type="submit">Filter</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form id="users-bulk-form" method="post" action="/account/users/bulk/disable">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<input type="hidden" name="selected_ids" value="" id="bulk-selected-ids">
|
||||
|
||||
<div class="users-bulk-strip">
|
||||
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
|
||||
<button class="small-action" type="submit" data-users-action="bulk-disable" onclick="setBulkAction('/account/users/bulk/disable')">Disable</button>
|
||||
<button class="small-action" type="submit" data-users-action="bulk-enable" onclick="setBulkAction('/account/users/bulk/enable')">Enable</button>
|
||||
<button class="small-action" type="submit" data-users-action="bulk-revoke" onclick="setBulkAction('/account/users/bulk/revoke-sessions')">Revoke sessions</button>
|
||||
<span class="stat-note-pill" id="selected-count">0 selected</span>
|
||||
</div>
|
||||
|
||||
<div class="section-body sunken-panel table-body-panel">
|
||||
<div class="table-scroll">
|
||||
<table class="account-table" aria-label="Users">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-cell"><input type="checkbox" id="master-check" aria-label="Select current page"></th>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Plan</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr data-user-id="{{ .ID }}">
|
||||
<td class="check-cell">
|
||||
<input type="checkbox" class="row-check" value="{{ .ID }}" data-user-id="{{ .ID }}" aria-label="Select {{ .Username }}">
|
||||
</td>
|
||||
<td class="user-cell">
|
||||
<div class="user-main">
|
||||
<span class="username">{{ .Username }}{{ if .IsCurrent }} <span class="pill is-info">you</span>{{ end }}</span>
|
||||
<span class="subtle">id: {{ .ID }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="email-cell" title="{{ .Email }}">{{ .Email }}</td>
|
||||
<td>
|
||||
{{ if eq .Status "active" }}
|
||||
<span class="pill is-ok">active</span>
|
||||
{{ else }}
|
||||
<span class="pill is-danger">disabled</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td><span class="pill is-info">{{ .Role }}</span></td>
|
||||
<td><span class="pill">{{ .Plan }}</span></td>
|
||||
<td>{{ .CreatedAt }}</td>
|
||||
<td class="actions-cell">
|
||||
<a class="tiny-button" href="/account/users/{{ .ID }}">Edit</a>
|
||||
{{ if and .IsInvite (not .IsCurrent) }}
|
||||
<form method="post" action="/account/users/{{ .ID }}/invite/resend" style="display:inline">
|
||||
{{ template "account_csrf_field" $ }}
|
||||
<button class="tiny-button" type="submit">Resend invite</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="8">No users found.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="pagination">
|
||||
<span class="pagination-info">
|
||||
Page {{ .Page }} of {{ .TotalPages }} — {{ .Total }} matching user(s)
|
||||
</span>
|
||||
<div class="pagination-controls">
|
||||
{{ if .HasPrev }}
|
||||
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .PrevPage }}">Prev</a>
|
||||
{{ else }}
|
||||
<button class="small-action" disabled>Prev</button>
|
||||
{{ end }}
|
||||
{{ if .HasNext }}
|
||||
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .NextPage }}">Next</a>
|
||||
{{ else }}
|
||||
<button class="small-action" disabled>Next</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Users status">
|
||||
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
Reference in New Issue
Block a user