From 82d4dc815bcc95f41b3105d6f70580992079b434 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Thu, 30 Apr 2026 21:45:09 +0300 Subject: [PATCH] feat(users): implement comprehensive user listing and control --- lib/metastore/models.go | 110 +++++- lib/metastore/permissions.go | 16 + lib/metastore/store.go | 290 +++++++++++++++ lib/metastore/tags.go | 1 + lib/server/account_auth.go | 12 + lib/server/account_test.go | 613 +++++++++++++++++++++++++++++++ lib/server/account_user_edit.go | 557 ++++++++++++++++++++++++++++ lib/server/admin_routes.go | 8 +- lib/server/admin_users.go | 440 +++++++++++++++++----- static/css/account.css | 472 ++++++++++++++++++++++++ static/js/account-user-edit.js | 101 +++++ static/js/account-users.js | 67 ++++ templates/account_user_edit.html | 323 ++++++++++++++++ templates/account_users.html | 257 +++++++++++++ 14 files changed, 3170 insertions(+), 97 deletions(-) create mode 100644 lib/server/account_user_edit.go create mode 100644 static/js/account-user-edit.js create mode 100644 static/js/account-users.js create mode 100644 templates/account_user_edit.html create mode 100644 templates/account_users.html diff --git a/lib/metastore/models.go b/lib/metastore/models.go index 0deb837..f1c021c 100644 --- a/lib/metastore/models.go +++ b/lib/metastore/models.go @@ -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 +} diff --git a/lib/metastore/permissions.go b/lib/metastore/permissions.go index 0a7954b..bfa77ea 100644 --- a/lib/metastore/permissions.go +++ b/lib/metastore/permissions.go @@ -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) diff --git a/lib/metastore/store.go b/lib/metastore/store.go index 13fecad..42480ed 100644 --- a/lib/metastore/store.go +++ b/lib/metastore/store.go @@ -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 { diff --git a/lib/metastore/tags.go b/lib/metastore/tags.go index 4252140..0696d96 100644 --- a/lib/metastore/tags.go +++ b/lib/metastore/tags.go @@ -22,6 +22,7 @@ func AdminPermissions() TagPermissions { ZipDownloadAllowed: true, RenewableAllowed: true, AdminAccess: true, + AdminUsersView: true, AdminUsersManage: true, AdminSettingsManage: true, AdminBoxesView: true, diff --git a/lib/server/account_auth.go b/lib/server/account_auth.go index 0601e78..67019d5 100644 --- a/lib/server/account_auth.go +++ b/lib/server/account_auth.go @@ -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) { diff --git a/lib/server/account_test.go b/lib/server/account_test.go index 282c68d..a700950 100644 --- a/lib/server/account_test.go +++ b/lib/server/account_test.go @@ -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) + } +} diff --git a/lib/server/account_user_edit.go b/lib/server/account_user_edit.go new file mode 100644 index 0000000..bdc9618 --- /dev/null +++ b/lib/server/account_user_edit.go @@ -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 +} diff --git a/lib/server/admin_routes.go b/lib/server/admin_routes.go index a077f37..28d8b56 100644 --- a/lib/server/admin_routes.go +++ b/lib/server/admin_routes.go @@ -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) diff --git a/lib/server/admin_users.go b/lib/server/admin_users.go index 1a9f4f1..b9fee41 100644 --- a/lib/server/admin_users.go +++ b/lib/server/admin_users.go @@ -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) } diff --git a/static/css/account.css b/static/css/account.css index ac4a29c..b579b4c 100644 --- a/static/css/account.css +++ b/static/css/account.css @@ -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; + } +} diff --git a/static/js/account-user-edit.js b/static/js/account-user-edit.js new file mode 100644 index 0000000..e8d71c1 --- /dev/null +++ b/static/js/account-user-edit.js @@ -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 }); + } +}()); diff --git a/static/js/account-users.js b/static/js/account-users.js new file mode 100644 index 0000000..dbc9014 --- /dev/null +++ b/static/js/account-users.js @@ -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; +} \ No newline at end of file diff --git a/templates/account_user_edit.html b/templates/account_user_edit.html new file mode 100644 index 0000000..2b1770a --- /dev/null +++ b/templates/account_user_edit.html @@ -0,0 +1,323 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + + + +
+ editing: {{ .Target.Username }} + signed in: {{ .AccountNav.Username }} + {{ .Status }} +
+
+{{ template "account_shell_end" . }} diff --git a/templates/account_users.html b/templates/account_users.html new file mode 100644 index 0000000..7ffb3eb --- /dev/null +++ b/templates/account_users.html @@ -0,0 +1,257 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + + + + +
+{{ template "account_shell_end" . }} \ No newline at end of file