feat(users): implement comprehensive user listing and control

This commit is contained in:
2026-04-30 21:45:09 +03:00
parent 89c885f637
commit 82d4dc815b
14 changed files with 3170 additions and 97 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -22,6 +22,7 @@ func AdminPermissions() TagPermissions {
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersView: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,

View File

@@ -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) {

View File

@@ -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)
}
}

View 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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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;
}
}

View 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 });
}
}());

View 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;
}

View 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" . }}

View 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 }} &mdash; {{ .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" . }}