From 76f9522c9be88c992dc320d86d6f3807fa6f9bff Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Thu, 24 Sep 2026 12:13:15 +0200 Subject: [PATCH] feat(admin): add user management --- .ai/HANDOFF.md | 28 ++++++ .ai/REVIEW.md | 21 ++++ .ai/TASKS.md | 2 +- README.md | 15 +++ internal/auth/store.go | 164 ++++++++++++++++++++++++++++++- internal/auth/users_test.go | 90 +++++++++++++++++ internal/web/admin_test.go | 149 +++++++++++++++++++++++++++++ internal/web/app.go | 186 ++++++++++++++++++++++++++++++++++-- 8 files changed, 642 insertions(+), 13 deletions(-) create mode 100644 internal/auth/users_test.go create mode 100644 internal/web/admin_test.go 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 ( `)) rootsTemplate = template.Must(template.New("roots").Parse(pageStart + `

webFileBrowser

-

Signed in as {{.UserEmail}}.

Roots

` + signOutForm + pageEnd)) +

Signed in as {{.UserEmail}}.

{{if .IsAdmin}}

Manage users

{{end}}

Roots

` + signOutForm + pageEnd)) directoryTemplate = template.Must(template.New("directory").Parse(pageStart + `

webFileBrowser

Signed in as {{.UserEmail}}.

{{range .Entries}}{{else}}{{end}}
NameTypeSizeModifiedActions
{{if .URL}}{{.Name}}{{else}}{{.Name}}{{end}}{{.Type}}{{.Size}}{{.Modified}}{{if .DownloadURL}}Download{{end}}{{if .StreamURL}} · Stream{{end}}
This folder is empty.
` + signOutForm + pageEnd)) + adminUsersTemplate = template.Must(template.New("admin-users").Parse(pageStart + ` +

User management

+

Signed in as {{.UserEmail}}. Back to roots

{{if .Error}}

{{.Error}}

{{end}} +

Create user

+ +
+

Users

{{range .Users}}{{else}}{{end}}
EmailRoleStatusPasswordActions
{{.Email}}{{.Role}}{{if .Disabled}}Disabled{{else}}Enabled{{end}}{{if .MustChangePassword}}Change required{{else}}Current{{end}}
No users exist.
` + signOutForm + pageEnd)) ) const pageStart = `webFileBrowser` +:root{color-scheme:light;--background:#f6f7fb;--surface:#fff;--text:#202534;--muted:#5d6577;--border:#d7dce8;--link:#2251b5}:root[data-theme="dark"]{color-scheme:dark;--background:#151821;--surface:#202532;--text:#f1f4fb;--muted:#b7c0d2;--border:#3c465b;--link:#9ec0ff}*{box-sizing:border-box}body{margin:0;background:var(--background);color:var(--text);font:16px system-ui,sans-serif}main{max-width:960px;margin:2rem auto;padding:2rem;background:var(--surface);border:1px solid var(--border);border-radius:12px}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}h1{margin:0}a{color:var(--link)}button{font:inherit;padding:.45rem .75rem}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;text-align:left;border-bottom:1px solid var(--border)}.roots,.breadcrumbs{padding-left:1.2rem}.breadcrumbs{display:flex;gap:.5rem;flex-wrap:wrap}.breadcrumbs li+li:before{content:"/";margin-right:.5rem;color:var(--muted)}form{margin-top:1.5rem}.compact{margin:.5rem 0}` const signOutForm = `
` @@ -110,6 +122,14 @@ func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Requ http.Redirect(writer, request, "/", http.StatusSeeOther) return } + if request.URL.Path == "/admin" || strings.HasPrefix(request.URL.Path, "/admin/") { + if user.Role != auth.RoleAdmin { + http.Error(writer, "Forbidden.", http.StatusForbidden) + return + } + app.handleAdmin(writer, request, user) + return + } switch request.URL.Path { case "/": app.handleRoots(writer, request, user) @@ -210,7 +230,146 @@ func (app *application) handleRoots(writer http.ResponseWriter, request *http.Re for _, root := range app.browser.Roots() { roots = append(roots, rootLink{Name: root.Name, URL: browseURL(root.Name, "")}) } - renderTemplate(writer, rootsTemplate, rootPage{UserEmail: user.Email, Roots: roots}) + renderTemplate(writer, rootsTemplate, rootPage{UserEmail: user.Email, IsAdmin: user.Role == auth.RoleAdmin, Roots: roots}) +} + +func (app *application) handleAdmin(writer http.ResponseWriter, request *http.Request, user auth.User) { + switch request.URL.Path { + case adminUsersPath: + app.handleAdminUsers(writer, request, user) + case adminCreateUserPath: + app.handleAdminCreateUser(writer, request, user) + case adminResetUserPath: + app.handleAdminResetUser(writer, request, user) + case adminDisableUserPath: + app.handleAdminDisableUser(writer, request, user) + case adminDeleteUserPath: + app.handleAdminDeleteUser(writer, request, user) + default: + http.NotFound(writer, request) + } +} + +func (app *application) handleAdminUsers(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodGet { + methodNotAllowed(writer, http.MethodGet) + return + } + app.renderAdminUsers(writer, http.StatusOK, user, "") +} + +func (app *application) handleAdminCreateUser(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodPost { + methodNotAllowed(writer, http.MethodPost) + return + } + if err := request.ParseForm(); err != nil { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Unable to read user details.") + return + } + _, err := app.store.CreateUser(request.FormValue("email"), request.FormValue("temporary_password"), auth.Role(request.FormValue("role"))) + if errors.Is(err, auth.ErrInvalidUser) { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Enter an email, temporary password, and valid role.") + return + } + if errors.Is(err, auth.ErrEmailInUse) { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "That email address is already in use.") + return + } + if err != nil { + http.Error(writer, "Unable to create user.", http.StatusInternalServerError) + return + } + http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther) +} + +func (app *application) handleAdminResetUser(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodPost { + methodNotAllowed(writer, http.MethodPost) + return + } + userID, ok := parseAdminUserID(writer, request, user, app) + if !ok { + return + } + err := app.store.ResetPassword(userID, request.FormValue("temporary_password")) + if errors.Is(err, auth.ErrInvalidUser) || errors.Is(err, auth.ErrUserNotFound) { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Enter an existing user and temporary password.") + return + } + if err != nil { + http.Error(writer, "Unable to reset password.", http.StatusInternalServerError) + return + } + http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther) +} + +func (app *application) handleAdminDisableUser(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodPost { + methodNotAllowed(writer, http.MethodPost) + return + } + userID, ok := parseAdminUserID(writer, request, user, app) + if !ok { + return + } + err := app.store.DisableUser(userID) + if errors.Is(err, auth.ErrInvalidUser) || errors.Is(err, auth.ErrUserNotFound) { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Select an existing user to disable.") + return + } + if err != nil { + http.Error(writer, "Unable to disable user.", http.StatusInternalServerError) + return + } + http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther) +} + +func (app *application) handleAdminDeleteUser(writer http.ResponseWriter, request *http.Request, user auth.User) { + if request.Method != http.MethodPost { + methodNotAllowed(writer, http.MethodPost) + return + } + userID, ok := parseAdminUserID(writer, request, user, app) + if !ok { + return + } + err := app.store.DeleteUser(userID) + if errors.Is(err, auth.ErrInvalidUser) || errors.Is(err, auth.ErrUserNotFound) { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Select an existing user to delete.") + return + } + if err != nil { + http.Error(writer, "Unable to delete user.", http.StatusInternalServerError) + return + } + http.Redirect(writer, request, adminUsersPath, http.StatusSeeOther) +} + +func parseAdminUserID(writer http.ResponseWriter, request *http.Request, user auth.User, app *application) (int64, bool) { + if err := request.ParseForm(); err != nil { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Unable to read user details.") + return 0, false + } + userID, err := strconv.ParseInt(request.FormValue("id"), 10, 64) + if err != nil || userID <= 0 { + app.renderAdminUsers(writer, http.StatusBadRequest, user, "Select an existing user.") + return 0, false + } + return userID, true +} + +func (app *application) renderAdminUsers(writer http.ResponseWriter, status int, user auth.User, message string) { + users, err := app.store.ListUsers() + if err != nil { + http.Error(writer, "Unable to load users.", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.WriteHeader(status) + if err := adminUsersTemplate.Execute(writer, adminUsersPage{UserEmail: user.Email, Users: users, Error: message}); err != nil { + http.Error(writer, "Unable to render page.", http.StatusInternalServerError) + } } func (app *application) handleBrowse(writer http.ResponseWriter, request *http.Request, user auth.User) { @@ -335,9 +494,16 @@ func (app *application) clearSessionCookie(writer http.ResponseWriter) { type rootPage struct { UserEmail string + IsAdmin bool Roots []rootLink } +type adminUsersPage struct { + UserEmail string + Users []auth.User + Error string +} + type rootLink struct { Name string URL string