diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md
index 4d6ed07..cbaec8b 100644
--- a/.ai/HANDOFF.md
+++ b/.ai/HANDOFF.md
@@ -162,3 +162,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t
| Next Role | none |
---
+
+### T-005 — dev — 2026-09-24T10:11:01Z
+
+| Field | Value |
+|-------|-------|
+| Agent | codex |
+| Summary | Added administrator user management with forced temporary-password changes, session invalidation, and strict 403 role protection. |
+| Files Changed | README.md, internal/auth/store.go, internal/auth/users_test.go, internal/web/app.go, internal/web/admin_test.go, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md |
+| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; E2E not configured. |
+| Commit | `feat(admin): add user management` |
+| Verdict | PASS |
+| Blocking Findings | none |
+| Next Role | human approval |
+
+---
+
+### T-005 — commit_task — 2026-09-24T10:12:06Z
+
+| Field | Value |
+|-------|-------|
+| Agent | codex |
+| Summary | Approved the reviewed T-005 administrator user-management work and prepared its single task commit. |
+| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
+| Validation | Review validation recorded in `.ai/REVIEW.md`. |
+| Commit | `feat(admin): add user management` |
+| Next Role | none |
+
+---
diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md
index ef38fbe..3c4e4b3 100644
--- a/.ai/REVIEW.md
+++ b/.ai/REVIEW.md
@@ -87,3 +87,24 @@ root-confined file opener, serve only regular files, and delegate byte-range
handling to Go's `http.ServeContent`. Tests verify the attachment's unmodified
bytes, content headers, `206 Partial Content`, `Content-Range`, and
`Accept-Ranges` behavior. No media processing code was added.
+
+## T-005 — 2026-09-24T10:11:01Z
+
+Verdict: PASS
+
+Findings: none.
+
+Validation:
+
+- `go fmt ./...` (in `golang:1.24`): PASS
+- `go vet ./...` (in `golang:1.24`): PASS
+- `go test ./...` (in `golang:1.24`): PASS
+- E2E: NOT_CONFIGURED; project documentation declares no E2E command.
+
+The review confirms that user-management routes are checked only after the
+existing authentication and mandatory-password-change gates. Every `/admin`
+route, including the namespace root, returns `403 Forbidden` with no redirect
+for an authenticated standard user. The new storage operations use bcrypt,
+force a password change after creates and resets, and invalidate sessions on
+resets and disables. README documents the administrator workflow and the
+standard-user boundary.
diff --git a/.ai/TASKS.md b/.ai/TASKS.md
index f0e599d..4dc76e1 100644
--- a/.ai/TASKS.md
+++ b/.ai/TASKS.md
@@ -24,5 +24,5 @@ Command expectations:
| T-002 | Data & auth foundation: SQLite schema/migrations for users + sessions, bcrypt password hashing, seed admin `boss@zecevic-ninja.com` / `Start1234!` with `must_change_password=true` on first startup, server-side session-cookie middleware (HTTP-only/Secure/SameSite, sliding expiry), login/logout routes, forced password-change gate that blocks every other route until the password is changed | done | Unauthenticated request to any non-login route (incl. file listing, download, stream, admin) returns redirect/401, never content; login with seed credentials succeeds and is immediately forced to the change-password screen and cannot reach any other page until changed; new password must differ from the temporary one; sessions persist across process restart via SQLite; unit tests cover auth middleware, forced-change gate, and password hashing | PASS: auth storage/bcrypt/persistence/sliding-session and HTTP login/logout/forced-change/protected-route tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; Docker image build and named-volume startup smoke test | none |
| T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | done | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | PASS: confined directory listing, traversal/symlink-escape rejection, authenticated root/folder/breadcrumb rendering, theme-local-storage markup; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |
| T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | done | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | PASS: root-confined regular-file opens; authenticated byte-identical attachment download; video `206` `Range` response; unauthenticated delivery gate; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |
-| T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | ready_for_implement | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | n/a | implement |
+| T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | done | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | PASS: admin lifecycle/session-invalidation and HTTP role-protection/temporary-password tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; E2E not configured | none |
| T-006 | Docker packaging & deployment docs: finalize multi-stage Dockerfile (small image, no build tools in final image), finalize docker-compose example with volume mounts + root config, complete `README.md` (build/run, root config format, first-login/admin credential flow, forced password change, reverse-proxy TLS + Range passthrough requirement) | ready_for_implement | `docker build` produces a working image; `docker run` with a sample volume mount and root config serves the login page only until authenticated, per Priority 1; `README.md` is complete enough for a new operator to deploy without reading source code | n/a | implement |
diff --git a/README.md b/README.md
index 7c1c9a0..6f80143 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,21 @@ The session cookie is always `HttpOnly`, `Secure`, and `SameSite=Lax`.
Terminate TLS at a reverse proxy before exposing the service; browsers will not
send the secure cookie over plain HTTP.
+## User administration
+
+After changing the seeded administrator password, use **Manage users** on the
+roots page to open `/admin/users`. Administrators can review every account,
+create either a user or administrator account, issue a replacement temporary
+password, disable an account, or delete it permanently. A newly created
+account and every password reset require the account holder to choose a new
+password before it can access the application.
+
+Disabling an account immediately prevents further sign-ins and invalidates its
+active sessions; deleting an account also removes its sessions. Standard users
+can browse, download, and stream configured roots, but every administration
+route responds with `403 Forbidden` for them without redirecting to an admin
+page.
+
## Browsing folders
After changing the temporary password, select a configured root from the home
diff --git a/internal/auth/store.go b/internal/auth/store.go
index 58db730..7ebb89c 100644
--- a/internal/auth/store.go
+++ b/internal/auth/store.go
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -30,24 +31,31 @@ var (
ErrPasswordUnchanged = errors.New("new password must differ from the current password")
// ErrSessionNotFound indicates an expired, deleted, or unknown session.
ErrSessionNotFound = errors.New("session not found")
+ // ErrUserNotFound indicates that an administrator targeted an unknown user.
+ ErrUserNotFound = errors.New("user not found")
+ // ErrInvalidUser indicates incomplete or unsupported account details.
+ ErrInvalidUser = errors.New("invalid user details")
+ // ErrEmailInUse indicates that another account already uses the email address.
+ ErrEmailInUse = errors.New("email address is already in use")
)
// Role controls a user's access level.
type Role string
const (
- // RoleAdmin can manage users in the later administration phase.
+ // RoleAdmin can manage user accounts.
RoleAdmin Role = "admin"
// RoleUser can browse configured roots after authentication.
RoleUser Role = "user"
)
-// User is the safe, password-hash-free representation of an account.
+// User is the safe, password-hash-free representation of an account and its status.
type User struct {
ID int64
Email string
Role Role
MustChangePassword bool
+ Disabled bool
}
// Options customizes the persistent authentication store.
@@ -128,9 +136,146 @@ func (store *Store) Authenticate(email, password string) (User, error) {
return User{}, ErrInvalidCredentials
}
user.MustChangePassword = mustChangePassword != 0
+ user.Disabled = disabled != 0
return user, nil
}
+// ListUsers returns all accounts, including disabled accounts, for an
+// administrator to manage.
+func (store *Store) ListUsers() ([]User, error) {
+ rows, err := store.db.Query(`
+ SELECT id, email, role, must_change_password, disabled
+ FROM users
+ ORDER BY email COLLATE NOCASE
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("list users: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ users := make([]User, 0)
+ for rows.Next() {
+ var user User
+ var mustChangePassword, disabled int
+ if err := rows.Scan(&user.ID, &user.Email, &user.Role, &mustChangePassword, &disabled); err != nil {
+ return nil, fmt.Errorf("scan user: %w", err)
+ }
+ user.MustChangePassword = mustChangePassword != 0
+ user.Disabled = disabled != 0
+ users = append(users, user)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate users: %w", err)
+ }
+ return users, nil
+}
+
+// CreateUser creates an enabled account whose temporary password must be
+// changed before it can access the application.
+func (store *Store) CreateUser(email, temporaryPassword string, role Role) (User, error) {
+ email = strings.TrimSpace(email)
+ if email == "" || temporaryPassword == "" || !validRole(role) {
+ return User{}, ErrInvalidUser
+ }
+
+ passwordHash, err := HashPassword(temporaryPassword)
+ if err != nil {
+ return User{}, fmt.Errorf("hash temporary password: %w", err)
+ }
+ now := store.nowUnix()
+ result, err := store.db.Exec(`
+ INSERT INTO users (email, password_hash, role, must_change_password, disabled, created_at, updated_at)
+ VALUES (?, ?, ?, 1, 0, ?, ?)
+ `, email, passwordHash, role, now, now)
+ if err != nil {
+ if strings.Contains(err.Error(), "UNIQUE constraint failed: users.email") {
+ return User{}, ErrEmailInUse
+ }
+ return User{}, fmt.Errorf("create user: %w", err)
+ }
+ userID, err := result.LastInsertId()
+ if err != nil {
+ return User{}, fmt.Errorf("read created user ID: %w", err)
+ }
+ return User{ID: userID, Email: email, Role: role, MustChangePassword: true}, nil
+}
+
+// ResetPassword replaces a user's password with a new temporary password and
+// invalidates every existing session for that user.
+func (store *Store) ResetPassword(userID int64, temporaryPassword string) error {
+ if userID <= 0 || temporaryPassword == "" {
+ return ErrInvalidUser
+ }
+ passwordHash, err := HashPassword(temporaryPassword)
+ if err != nil {
+ return fmt.Errorf("hash temporary password: %w", err)
+ }
+ transaction, err := store.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin password reset: %w", err)
+ }
+ defer func() { _ = transaction.Rollback() }()
+
+ result, err := transaction.Exec(`
+ UPDATE users
+ SET password_hash = ?, must_change_password = 1, updated_at = ?
+ WHERE id = ?
+ `, passwordHash, store.nowUnix(), userID)
+ if err != nil {
+ return fmt.Errorf("reset password: %w", err)
+ }
+ if err := requireAffectedUser(result); err != nil {
+ return err
+ }
+ if _, err := transaction.Exec("DELETE FROM sessions WHERE user_id = ?", userID); err != nil {
+ return fmt.Errorf("remove reset user sessions: %w", err)
+ }
+ if err := transaction.Commit(); err != nil {
+ return fmt.Errorf("commit password reset: %w", err)
+ }
+ return nil
+}
+
+// DisableUser prevents sign-in and invalidates every active session for the
+// account while preserving it for later administrative review or deletion.
+func (store *Store) DisableUser(userID int64) error {
+ if userID <= 0 {
+ return ErrInvalidUser
+ }
+ transaction, err := store.db.Begin()
+ if err != nil {
+ return fmt.Errorf("begin user disable: %w", err)
+ }
+ defer func() { _ = transaction.Rollback() }()
+
+ result, err := transaction.Exec("UPDATE users SET disabled = 1, updated_at = ? WHERE id = ?", store.nowUnix(), userID)
+ if err != nil {
+ return fmt.Errorf("disable user: %w", err)
+ }
+ if err := requireAffectedUser(result); err != nil {
+ return err
+ }
+ if _, err := transaction.Exec("DELETE FROM sessions WHERE user_id = ?", userID); err != nil {
+ return fmt.Errorf("remove disabled user sessions: %w", err)
+ }
+ if err := transaction.Commit(); err != nil {
+ return fmt.Errorf("commit user disable: %w", err)
+ }
+ return nil
+}
+
+// DeleteUser permanently removes an account and its server-side sessions.
+func (store *Store) DeleteUser(userID int64) error {
+ if userID <= 0 {
+ return ErrInvalidUser
+ }
+ result, err := store.db.Exec("DELETE FROM users WHERE id = ?", userID)
+ if err != nil {
+ return fmt.Errorf("delete user: %w", err)
+ }
+ return requireAffectedUser(result)
+}
+
// ChangePassword verifies the current password, rejects reuse, and clears the
// forced-password-change flag.
func (store *Store) ChangePassword(userID int64, currentPassword, newPassword string) error {
@@ -331,6 +476,21 @@ func (store *Store) nowUnix() int64 {
return store.clock().UTC().Unix()
}
+func validRole(role Role) bool {
+ return role == RoleAdmin || role == RoleUser
+}
+
+func requireAffectedUser(result sql.Result) error {
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("read affected users: %w", err)
+ }
+ if affected == 0 {
+ return ErrUserNotFound
+ }
+ return nil
+}
+
func randomToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
diff --git a/internal/auth/users_test.go b/internal/auth/users_test.go
new file mode 100644
index 0000000..0330c24
--- /dev/null
+++ b/internal/auth/users_test.go
@@ -0,0 +1,90 @@
+package auth
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestUserManagementLifecycle(t *testing.T) {
+ store := openTestStore(t, t.TempDir())
+
+ created, err := store.CreateUser("reader@example.com", "Temporary-1!", RoleUser)
+ if err != nil {
+ t.Fatalf("CreateUser() error = %v", err)
+ }
+ if created.Role != RoleUser || !created.MustChangePassword || created.Disabled {
+ t.Errorf("CreateUser() = %#v, want enabled user role with forced password change", created)
+ }
+
+ user, err := store.Authenticate(created.Email, "Temporary-1!")
+ if err != nil {
+ t.Fatalf("Authenticate(created user) error = %v", err)
+ }
+ if !user.MustChangePassword {
+ t.Error("created user's temporary password does not require a change")
+ }
+ token, err := store.CreateSession(user.ID)
+ if err != nil {
+ t.Fatalf("CreateSession() error = %v", err)
+ }
+
+ if err := store.ResetPassword(created.ID, "Replacement-2!"); err != nil {
+ t.Fatalf("ResetPassword() error = %v", err)
+ }
+ if _, err := store.LookupSession(token); !errors.Is(err, ErrSessionNotFound) {
+ t.Errorf("LookupSession() after reset error = %v, want ErrSessionNotFound", err)
+ }
+ if _, err := store.Authenticate(created.Email, "Temporary-1!"); !errors.Is(err, ErrInvalidCredentials) {
+ t.Errorf("Authenticate(old password) error = %v, want ErrInvalidCredentials", err)
+ }
+ user, err = store.Authenticate(created.Email, "Replacement-2!")
+ if err != nil {
+ t.Fatalf("Authenticate(reset password) error = %v", err)
+ }
+ if !user.MustChangePassword {
+ t.Error("reset password does not require a change")
+ }
+ token, err = store.CreateSession(user.ID)
+ if err != nil {
+ t.Fatalf("CreateSession() after reset error = %v", err)
+ }
+
+ if err := store.DisableUser(created.ID); err != nil {
+ t.Fatalf("DisableUser() error = %v", err)
+ }
+ if _, err := store.Authenticate(created.Email, "Replacement-2!"); !errors.Is(err, ErrInvalidCredentials) {
+ t.Errorf("Authenticate(disabled user) error = %v, want ErrInvalidCredentials", err)
+ }
+ if _, err := store.LookupSession(token); !errors.Is(err, ErrSessionNotFound) {
+ t.Errorf("LookupSession() for disabled user error = %v, want ErrSessionNotFound", err)
+ }
+
+ users, err := store.ListUsers()
+ if err != nil {
+ t.Fatalf("ListUsers() error = %v", err)
+ }
+ managed := userByEmail(users, created.Email)
+ if managed == nil || !managed.Disabled {
+ t.Errorf("ListUsers() did not return %q as disabled: %#v", created.Email, users)
+ }
+
+ if err := store.DeleteUser(created.ID); err != nil {
+ t.Fatalf("DeleteUser() error = %v", err)
+ }
+ users, err = store.ListUsers()
+ if err != nil {
+ t.Fatalf("ListUsers() after deletion error = %v", err)
+ }
+ if userByEmail(users, created.Email) != nil {
+ t.Errorf("ListUsers() still returned deleted user %q", created.Email)
+ }
+}
+
+func userByEmail(users []User, email string) *User {
+ for index := range users {
+ if users[index].Email == email {
+ return &users[index]
+ }
+ }
+ return nil
+}
diff --git a/internal/web/admin_test.go b/internal/web/admin_test.go
new file mode 100644
index 0000000..cf502b1
--- /dev/null
+++ b/internal/web/admin_test.go
@@ -0,0 +1,149 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+
+ "webfilebrowser/internal/auth"
+)
+
+func TestNonAdminUsersAreForbiddenFromEveryAdminRoute(t *testing.T) {
+ store, handler := newTestApplication(t)
+ created, err := store.CreateUser("reader@example.com", "Temporary-1!", auth.RoleUser)
+ if err != nil {
+ t.Fatalf("CreateUser() error = %v", err)
+ }
+ userCookie := completeUserPasswordChange(t, handler, created.Email, "Temporary-1!", "Reader-password-2!")
+
+ for _, target := range []struct {
+ method string
+ path string
+ }{
+ {method: http.MethodGet, path: "/admin"},
+ {method: http.MethodGet, path: adminUsersPath},
+ {method: http.MethodPost, path: adminCreateUserPath},
+ {method: http.MethodPost, path: adminResetUserPath},
+ {method: http.MethodPost, path: adminDisableUserPath},
+ {method: http.MethodPost, path: adminDeleteUserPath},
+ } {
+ t.Run(target.method+" "+target.path, func(t *testing.T) {
+ response := postOrGetWithCookie(handler, target.method, target.path, userCookie)
+ if response.Code != http.StatusForbidden {
+ t.Errorf("%s %s status = %d, want %d", target.method, target.path, response.Code, http.StatusForbidden)
+ }
+ if response.Header().Get("Location") != "" {
+ t.Errorf("%s %s redirected to %q, want no redirect", target.method, target.path, response.Header().Get("Location"))
+ }
+ })
+ }
+}
+
+func TestAdminCanManageUsersAndTemporaryPasswordsRequireChange(t *testing.T) {
+ store, handler := newTestApplication(t)
+ adminCookie := completeSeedPasswordChange(t, handler)
+
+ usersResponse := getWithCookie(handler, adminUsersPath, adminCookie)
+ if usersResponse.Code != http.StatusOK || !strings.Contains(usersResponse.Body.String(), "Create user") {
+ t.Fatalf("GET %s = (%d, %q), want admin management page", adminUsersPath, usersResponse.Code, usersResponse.Body.String())
+ }
+
+ createResponse := postForm(handler, adminCreateUserPath, url.Values{
+ "email": {"new-reader@example.com"},
+ "temporary_password": {"Temporary-1!"},
+ "role": {string(auth.RoleUser)},
+ }, adminCookie)
+ if createResponse.Code != http.StatusSeeOther || createResponse.Header().Get("Location") != adminUsersPath {
+ t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminCreateUserPath, createResponse.Code, createResponse.Header().Get("Location"), adminUsersPath)
+ }
+
+ created := managedUser(t, store, "new-reader@example.com")
+ loginResponse := postForm(handler, loginPath, url.Values{
+ "email": {created.Email},
+ "password": {"Temporary-1!"},
+ }, nil)
+ if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != changePasswordPath {
+ t.Fatalf("new user login = (%d, %q), want redirect to %s", loginResponse.Code, loginResponse.Header().Get("Location"), changePasswordPath)
+ }
+
+ resetResponse := postForm(handler, adminResetUserPath, url.Values{
+ "id": {strconv.FormatInt(created.ID, 10)},
+ "temporary_password": {"Replacement-2!"},
+ }, adminCookie)
+ if resetResponse.Code != http.StatusSeeOther || resetResponse.Header().Get("Location") != adminUsersPath {
+ t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminResetUserPath, resetResponse.Code, resetResponse.Header().Get("Location"), adminUsersPath)
+ }
+ if _, err := store.Authenticate(created.Email, "Replacement-2!"); err != nil {
+ t.Fatalf("Authenticate(reset user) error = %v", err)
+ }
+
+ disableResponse := postForm(handler, adminDisableUserPath, url.Values{"id": {strconv.FormatInt(created.ID, 10)}}, adminCookie)
+ if disableResponse.Code != http.StatusSeeOther || disableResponse.Header().Get("Location") != adminUsersPath {
+ t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminDisableUserPath, disableResponse.Code, disableResponse.Header().Get("Location"), adminUsersPath)
+ }
+ if _, err := store.Authenticate(created.Email, "Replacement-2!"); !errors.Is(err, auth.ErrInvalidCredentials) {
+ t.Errorf("Authenticate(disabled user) error = %v, want invalid credentials", err)
+ }
+
+ deleteResponse := postForm(handler, adminDeleteUserPath, url.Values{"id": {strconv.FormatInt(created.ID, 10)}}, adminCookie)
+ if deleteResponse.Code != http.StatusSeeOther || deleteResponse.Header().Get("Location") != adminUsersPath {
+ t.Fatalf("POST %s = (%d, %q), want redirect to %s", adminDeleteUserPath, deleteResponse.Code, deleteResponse.Header().Get("Location"), adminUsersPath)
+ }
+ if got := managedUserOrNil(t, store, created.Email); got != nil {
+ t.Errorf("deleted user remains in management list: %#v", got)
+ }
+}
+
+func completeUserPasswordChange(t *testing.T, handler http.Handler, email, temporaryPassword, newPassword string) *http.Cookie {
+ t.Helper()
+ loginResponse := postForm(handler, loginPath, url.Values{
+ "email": {email},
+ "password": {temporaryPassword},
+ }, nil)
+ if loginResponse.Code != http.StatusSeeOther || loginResponse.Header().Get("Location") != changePasswordPath {
+ t.Fatalf("temporary user login = (%d, %q), want redirect to %s", loginResponse.Code, loginResponse.Header().Get("Location"), changePasswordPath)
+ }
+ cookie := responseCookie(t, loginResponse, sessionCookieName)
+ changeResponse := postForm(handler, changePasswordPath, url.Values{
+ "current_password": {temporaryPassword},
+ "new_password": {newPassword},
+ }, cookie)
+ if changeResponse.Code != http.StatusSeeOther {
+ t.Fatalf("POST %s = %d, want %d", changePasswordPath, changeResponse.Code, http.StatusSeeOther)
+ }
+ return cookie
+}
+
+func postOrGetWithCookie(handler http.Handler, method, target string, cookie *http.Cookie) *httptest.ResponseRecorder {
+ if method == http.MethodGet {
+ return getWithCookie(handler, target, cookie)
+ }
+ return postForm(handler, target, url.Values{}, cookie)
+}
+
+func managedUser(t *testing.T, store *auth.Store, email string) auth.User {
+ t.Helper()
+ user := managedUserOrNil(t, store, email)
+ if user == nil {
+ t.Fatalf("ListUsers() did not return %q", email)
+ }
+ return *user
+}
+
+func managedUserOrNil(t *testing.T, store *auth.Store, email string) *auth.User {
+ t.Helper()
+ users, err := store.ListUsers()
+ if err != nil {
+ t.Fatalf("ListUsers() error = %v", err)
+ }
+ for index := range users {
+ if users[index].Email == email {
+ return &users[index]
+ }
+ }
+ return nil
+}
diff --git a/internal/web/app.go b/internal/web/app.go
index 11eb483..7cab541 100644
--- a/internal/web/app.go
+++ b/internal/web/app.go
@@ -19,13 +19,18 @@ import (
)
const (
- loginPath = "/login"
- logoutPath = "/logout"
- changePasswordPath = "/change-password"
- browsePath = "/browse"
- downloadPath = "/download"
- streamPath = "/stream"
- sessionCookieName = "webfilebrowser_session"
+ loginPath = "/login"
+ logoutPath = "/logout"
+ changePasswordPath = "/change-password"
+ browsePath = "/browse"
+ downloadPath = "/download"
+ streamPath = "/stream"
+ adminUsersPath = "/admin/users"
+ adminCreateUserPath = "/admin/users/create"
+ adminResetUserPath = "/admin/users/reset"
+ adminDisableUserPath = "/admin/users/disable"
+ adminDeleteUserPath = "/admin/users/delete"
+ sessionCookieName = "webfilebrowser_session"
)
var (
@@ -41,15 +46,22 @@ var (