diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go index 0ad7d80..baa9b5b 100644 --- a/internal/server/handlers_admin.go +++ b/internal/server/handlers_admin.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "html" "log" "net/http" "strings" @@ -44,136 +43,11 @@ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - methodNotAllowed(w, http.MethodGet) - return - } - if !s.requireAdminCookie(w, r) { - return - } - - var userCount, deviceCount, opsCount int - s.db.QueryRow("SELECT COUNT(*) FROM server_users").Scan(&userCount) - s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount) - s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount) - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(`Admin Dashboard - -

Verstak Sync Server — Admin

-
` + intToStr(userCount) + `
Users
-
` + intToStr(deviceCount) + `
Devices
-
` + intToStr(opsCount) + `
Sync Ops
-

Users | Devices | Health

-`)) -} - -func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - methodNotAllowed(w, http.MethodGet) - return - } - if !s.requireAdminCookie(w, r) { - return - } - rows, err := s.db.Query("SELECT id, username, email, confirmed, blocked, created_at FROM server_users ORDER BY created_at DESC") - if err != nil { - log.Printf("admin users: query failed: %v", err) - http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError) - return - } - defer rows.Close() - - var users []map[string]interface{} - for rows.Next() { - var id, username, email, createdAt string - var confirmed, blocked int - rows.Scan(&id, &username, &email, &confirmed, &blocked, &createdAt) - users = append(users, map[string]interface{}{ - "id": id, "username": username, "email": email, - "confirmed": confirmed, "blocked": blocked, "created_at": createdAt, - }) - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(`Users - -

Users ← Dashboard

-`)) - - for _, u := range users { - confirmed := "✅" - if u["confirmed"].(int) == 0 { - confirmed = "❌" - } - blocked := "" - if u["blocked"].(int) != 0 { - blocked = "🚫" - } - w.Write([]byte(``)) - } - w.Write([]byte(`
UsernameEmailConfirmedBlockedCreated
` + html.EscapeString(u["username"].(string)) + `` + html.EscapeString(u["email"].(string)) + - `` + confirmed + `` + blocked + `` + html.EscapeString(u["created_at"].(string)) + `
`)) -} - -func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - methodNotAllowed(w, http.MethodGet) - return - } - if !s.requireAdminCookie(w, r) { - return - } - rows, err := s.db.Query(`SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at - FROM server_devices d ORDER BY d.created_at DESC`) - if err != nil { - log.Printf("admin devices: query failed: %v", err) - http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError) - return - } - defer rows.Close() - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(`Devices - -

Devices ← Dashboard

-`)) - - for rows.Next() { - var id, name, clientVer, lastSeen, revokedAt, createdAt string - rows.Scan(&id, &name, &clientVer, &lastSeen, &revokedAt, &createdAt) - if lastSeen == "" { - lastSeen = "never" - } - if revokedAt == "" { - revokedAt = "-" - } - w.Write([]byte(``)) - } - w.Write([]byte(`
NameIDVersionLast SeenRevokedCreated
` + html.EscapeString(name) + `` + html.EscapeString(id) + - `` + html.EscapeString(clientVer) + `` + html.EscapeString(lastSeen) + `` + html.EscapeString(revokedAt) + `` + html.EscapeString(createdAt) + `
`)) -} - func (s *Server) requireAdminCookie(w http.ResponseWriter, r *http.Request) bool { _, ok := s.requireSession(w, r, sessionScopeAdmin) return ok } -func intToStr(n int) string { - b, _ := json.Marshal(n) - return strings.Trim(string(b), "\"") -} - func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w, http.MethodGet) @@ -562,75 +436,6 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques jsonErr(w, 404, "unknown action") } -func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { - if !s.requireAdminCookie(w, r) { - return - } - locale := s.locale() - switch r.Method { - case "GET": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - csrf := "" - if cookie, err := r.Cookie("csrf_token"); err == nil { - csrf = cookie.Value - } - w.Write([]byte(adminCreateUserHTML(locale, csrf))) - case "POST": - if !s.requireAdminMutation(w, r) { - return - } - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", 400) - return - } - username := r.FormValue("username") - email := r.FormValue("email") - password := r.FormValue("password") - if username == "" || email == "" || password == "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(400) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/admin/create-user"))) - return - } - if err := validatePassword(password); err != "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(400) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/admin/create-user"))) - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(500) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/admin/create-user"))) - return - } - now := time.Now().UTC().Format(time.RFC3339) - id := make([]byte, 12) - rand.Read(id) - userID := hex.EncodeToString(id) - _, err = s.db.Exec( - "INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 1, ?)", - userID, username, strings.ToLower(email), string(hash), now, - ) - if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if strings.Contains(err.Error(), "UNIQUE") { - w.WriteHeader(409) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/admin/create-user"))) - } else { - log.Printf("admin create user: failed: %v", err) - w.WriteHeader(500) - w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "admin.createUserFailed"), "/admin/create-user"))) - } - return - } - http.Redirect(w, r, "/admin/users", http.StatusFound) - default: - http.Error(w, "method not allowed", 405) - } -} - func (s *Server) handleAdminAPICreateUser(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { methodNotAllowed(w, "POST") diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 7e93560..2e1216e 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -3,7 +3,6 @@ package server import ( "crypto/rand" "encoding/hex" - "html" "log" "net/http" "strings" @@ -82,11 +81,11 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { tokenStr := r.URL.Query().Get("token") if tokenStr == "" { - jsonErrCode(w, http.StatusBadRequest, "invalid_request", "token required") + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login") return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(`
`)) + w.Header().Set("Cache-Control", "no-store") + s.renderPage(w, r, "confirm", webPage{Title: "confirm.title", Token: tokenStr}) return } if r.Method != http.MethodPost { @@ -109,19 +108,31 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { return } if tokenStr == "" { - jsonErr(w, 400, "token required") + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + jsonErr(w, 400, "token required") + } else { + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login") + } return } var userID, expiresAt string err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='confirm'", emailTokenHash(tokenStr)).Scan(&userID, &expiresAt) if err != nil { - jsonErr(w, 400, "invalid or expired token") + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + jsonErr(w, 400, "invalid or expired token") + } else { + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login") + } return } exp, err := time.Parse(time.RFC3339, expiresAt) if err != nil || time.Now().After(exp) { - jsonErr(w, 400, "token expired") + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + jsonErr(w, 400, "token expired") + } else { + s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login") + } return } tx, err := s.db.Begin() @@ -143,7 +154,11 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { return } log.Printf("confirm: user %s confirmed email", userID) - jsonOK(w, map[string]string{"status": "confirmed"}) + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + jsonOK(w, map[string]string{"status": "confirmed"}) + } else { + http.Redirect(w, r, "/confirm/result", http.StatusSeeOther) + } } func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/locale.go b/internal/server/locale.go index 962cd84..ece628a 100644 --- a/internal/server/locale.go +++ b/internal/server/locale.go @@ -60,6 +60,11 @@ var _translations = map[string]map[string]string{ "forgot.sentMessage": "Если аккаунт существует, ссылка для сброса уже отправлена.", "reset.doneTitle": "Пароль изменён", "reset.doneMessage": "Теперь можно войти с новым паролем.", + "confirm.title": "Подтвердите email", + "confirm.description": "Подтвердите адрес email, чтобы активировать учётную запись.", + "confirm.action": "Подтвердить email", + "confirm.resultTitle": "Email подтверждён", + "confirm.resultMessage": "Учётная запись активирована. Теперь можно войти.", "common.continue": "Продолжить", "common.back": "Назад", "common.actions": "Действия", @@ -102,6 +107,14 @@ var _translations = map[string]map[string]string{ "admin.healthJSON": "Открыть health JSON", "admin.manage": "Управление", "admin.saveUser": "Сохранить пользователя", + "admin.search": "Поиск", + "admin.all": "Все", + "admin.applyFilters": "Применить", + "admin.pagination": "Пагинация", + "admin.previous": "Назад", + "admin.next": "Далее", + "admin.runCleanup": "Запустить безопасную очистку", + "admin.downloadDiagnostics": "Скачать диагностику", "user.account": "Моя учётная запись", "user.devices": "Подключённые устройства", "user.noDevices": "Устройств пока нет", @@ -285,6 +298,11 @@ var _translations = map[string]map[string]string{ "forgot.sentMessage": "If the account exists, a reset link has been sent.", "reset.doneTitle": "Password changed", "reset.doneMessage": "You can now sign in with your new password.", + "confirm.title": "Confirm your email", + "confirm.description": "Confirm your email address to activate the account.", + "confirm.action": "Confirm email", + "confirm.resultTitle": "Email confirmed", + "confirm.resultMessage": "Your account is active. You can now sign in.", "common.continue": "Continue", "common.back": "Back", "common.actions": "Actions", @@ -327,6 +345,14 @@ var _translations = map[string]map[string]string{ "admin.healthJSON": "Open health JSON", "admin.manage": "Manage", "admin.saveUser": "Save user", + "admin.search": "Search", + "admin.all": "All", + "admin.applyFilters": "Apply", + "admin.pagination": "Pagination", + "admin.previous": "Previous", + "admin.next": "Next", + "admin.runCleanup": "Run safe cleanup", + "admin.downloadDiagnostics": "Download diagnostics", "user.account": "My account", "user.devices": "Connected devices", "user.noDevices": "No devices yet", diff --git a/internal/server/routes.go b/internal/server/routes.go index eb05e63..6c00dee 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,6 +6,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/register/result", s.handleRegistrationResult) s.mux.HandleFunc("/forgot/sent", s.handleForgotSent) s.mux.HandleFunc("/reset/done", s.handleResetDone) + s.mux.HandleFunc("/confirm/result", s.handleConfirmResult) s.mux.HandleFunc("/", s.handleHome) s.mux.HandleFunc("/api/v1/health", s.handleHealth) s.mux.HandleFunc("/livez", s.handleLiveness) @@ -46,6 +47,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/admin/audit", s.handleAdminWeb) s.mux.HandleFunc("/admin/settings", s.handleAdminWeb) s.mux.HandleFunc("/admin/diagnostics", s.handleAdminWeb) + s.mux.HandleFunc("/admin/diagnostics.json", s.handleAdminDiagnosticsJSON) s.mux.HandleFunc("/admin/api/stats", s.handleAdminStats) s.mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest) s.mux.HandleFunc("/admin/api/smtp", s.handleAdminAPISmtp) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 291bbef..c23ffab 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -64,23 +64,6 @@ func TestConfigSetAdmin(t *testing.T) { } } -func TestAdminDashboardSMTPSecuritySelectUsesApplicationStyles(t *testing.T) { - html := adminDashboardHTML("en", 0, 0, "", "", "", "", "starttls", "") - - for _, expected := range []string{ - ` - - - - - -

%s %s

- -`, - t(locale, "server.registerTitle"), - t(locale, "server.register"), - t(locale, "server.username"), - t(locale, "server.email"), - t(locale, "server.password"), - t(locale, "server.registerBtn"), - t(locale, "server.alreadyHaveAccount"), - t(locale, "server.loginBtn"), - ) -} - -func userLoginHTML(locale string) string { - return fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

Verstak Sync

- - - - - - -
-`, - t(locale, "server.loginTitle"), - t(locale, "server.usernameOrEmail"), - t(locale, "server.password"), - t(locale, "server.loginBtn"), - t(locale, "server.forgotPassword"), - t(locale, "server.registerBtn"), - t(locale, "server.adminLink"), - ) -} - -func confirmedHTML(locale string) string { - return fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

%s

-

%s

-%s -
-`, - t(locale, "server.emailConfirmed"), - t(locale, "server.emailConfirmed"), - t(locale, "server.emailConfirmedMessage"), - t(locale, "server.loginBtn"), - ) -} - -func registrationOKHTML(locale string) string { - return fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

%s

-

%s

-

%s

-%s -
-`, - t(locale, "server.registerTitle"), - t(locale, "server.registrationSuccess"), - t(locale, "server.registrationEmailSent"), - t(locale, "server.registrationCheckEmail"), - t(locale, "server.loginBtn"), - ) -} - -func registrationAutoHTML(locale string) string { - return fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

%s

-

%s

-%s -
-`, - t(locale, "server.registerTitle"), - t(locale, "server.registrationSuccess"), - t(locale, "server.registrationAutoMessage"), - t(locale, "server.loginBtn"), - ) -} - -func forgotPasswordHTML(locale string) string { - return fmt.Sprintf(` - - -%s - - -
-

%s

-

%s

- - - - -
-`, - t(locale, "server.resetPasswordTitle"), - t(locale, "server.resetPassword"), - t(locale, "server.resetInstruction"), - t(locale, "server.email"), - t(locale, "server.sendLink"), - t(locale, "server.backToLogin"), - ) -} - -func forgotSentHTML(locale string) string { - return fmt.Sprintf(` - - -%s - - -
-

%s

-

%s

-%s -
-`, - t(locale, "server.emailSentTitle"), - t(locale, "server.emailSent"), - t(locale, "server.emailSentMessage"), - t(locale, "server.goHome"), - ) -} - -func resetPasswordHTML(locale string) string { - return fmt.Sprintf(` - - -%s - - -
-

%s

- - - - - - -
-`, - t(locale, "server.newPasswordTitle"), - t(locale, "server.newPassword"), - t(locale, "server.password"), - t(locale, "server.passwordConfirm"), - t(locale, "server.save"), - ) -} - -func resetDoneHTML(locale string) string { - return fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

%s

-

%s

-%s -
-`, - t(locale, "server.passwordChanged"), - t(locale, "server.passwordChanged"), - t(locale, "server.passwordChangedMessage"), - t(locale, "server.loginBtn"), - ) -} - -func adminDashboardHTML(locale string, deviceCount, opsCount int, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL string) string { - return fmt.Sprintf(` - - -%[1]s - - -

Verstak Sync Server

-
-
%[2]s %[40]d
-
%[3]s %[41]d
-
- -
- - - -
- -

%[4]s

-
- - - - - - -`, - t(locale, "admin.dashboard"), - t(locale, "admin.deviceCount"), - t(locale, "admin.opsCount"), - t(locale, "admin.devices"), - t(locale, "admin.noDevices"), - t(locale, "admin.device"), - t(locale, "admin.user"), - t(locale, "admin.version"), - t(locale, "admin.status"), - t(locale, "admin.lastSeen"), - t(locale, "admin.active"), - t(locale, "admin.revoked"), - t(locale, "admin.revoke"), - t(locale, "common.loading"), - t(locale, "admin.smtp"), - t(locale, "admin.users"), - t(locale, "admin.healthCheck"), - t(locale, "admin.smtpServer"), - t(locale, "admin.smtpPort"), - t(locale, "admin.smtpType"), - t(locale, "admin.smtpNoEncryption"), - t(locale, "admin.smtpUsername"), - t(locale, "admin.smtpPassword"), - t(locale, "admin.smtpFrom"), - t(locale, "admin.smtpServerURL"), - t(locale, "admin.smtpSave"), - t(locale, "admin.smtpTest"), - t(locale, "admin.smtpTitle"), - t(locale, "admin.smtpTesting"), - t(locale, "admin.smtpPassed"), - t(locale, "admin.revokeConfirm"), - smtpHost, - smtpPort, - sel(smtpSecurity, "starttls"), - sel(smtpSecurity, "tls"), - sel(smtpSecurity, "none"), - smtpUser, - smtpFrom, - srvURL, - deviceCount, - opsCount, - ) -} - -func userDashboardHTML(locale, username, deviceRows, csrf string) string { - return fmt.Sprintf(` - - -Verstak Sync — %[1]s - - -
-

Verstak Sync

-%[1]s ·
-
-

%[3]s

-%[9]s
%[4]s%[5]s%[6]s%[7]s%[8]s
- -
-

%[10]s

-

%[11]s

-
- - -`, - username, - t(locale, "server.logout"), - t(locale, "userDashboard.devices"), - t(locale, "userDashboard.device"), - t(locale, "userDashboard.status"), - t(locale, "userDashboard.connected"), - t(locale, "userDashboard.lastSeen"), - t(locale, "userDashboard.version"), - deviceRows, - t(locale, "userDashboard.connectNew"), - t(locale, "userDashboard.connectNewHint"), - t(locale, "userDashboard.revokeConfirm"), - t(locale, "userDashboard.revokePrompt"), - html.EscapeString(csrf), - ) -} - -func adminCreateUserHTML(locale, csrf string) string { - return fmt.Sprintf(` - - -%[1]s - - -
- -

%[2]s

- - - - - - - -

%[7]s

-
-`, - t(locale, "admin.createUser"), - t(locale, "admin.createUser"), - t(locale, "server.username"), - t(locale, "server.email"), - t(locale, "server.password"), - t(locale, "admin.createUserBtn"), - t(locale, "server.dashboard"), - html.EscapeString(csrf), - ) -} - -func errorPageHTML(locale, title, msg, backURL string) string { - title = html.EscapeString(title) - msg = html.EscapeString(msg) - backURL = html.EscapeString(backURL) - return fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

%s

-

%s

-%s -
-`, title, title, msg, backURL, t(locale, "server.back")) -} - -func adminUsersHTML(locale string) string { - newPassResult := t(locale, "server.newPasswordResult") - newPassParts := strings.SplitN(newPassResult, "%s", 2) - newPassPrefix := newPassParts[0] - newPassSuffix := "" - if len(newPassParts) > 1 { - newPassSuffix = strings.ReplaceAll(newPassParts[1], "\n", "\\n") - } - - deleteMsg := t(locale, "admin.deleteUserMessage") - deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2) - delMsgPrefix := deleteMsgParts[0] - delMsgSuffix := "" - if len(deleteMsgParts) > 1 { - delMsgSuffix = deleteMsgParts[1] - } - - return fmt.Sprintf(` - - -%[1]s - - -

%[2]s

-

%[3]s

- -
- - -
- - - - - - - - - - - -
%[5]s %[6]s %[7]s %[8]s %[9]s %[10]s
- - - - - - - - - - -`, - t(locale, "admin.users"), - t(locale, "admin.usersHeading"), - t(locale, "server.dashboard"), - t(locale, "admin.filterPlaceholder"), - t(locale, "admin.username"), - t(locale, "admin.email"), - t(locale, "admin.status"), - t(locale, "admin.devices"), - t(locale, "admin.lastSeen"), - t(locale, "admin.actions"), - t(locale, "admin.confirmTitle"), - t(locale, "admin.modalCancel"), - t(locale, "admin.modalConfirm"), - t(locale, "admin.editUser"), - t(locale, "admin.username"), - t(locale, "admin.email"), - t(locale, "admin.modalCancel"), - t(locale, "admin.editBtn"), - t(locale, "admin.resultTitle"), - t(locale, "common.ok"), - t(locale, "admin.confirmed"), - t(locale, "admin.unconfirmed"), - t(locale, "admin.blocked"), - t(locale, "admin.unblock"), - t(locale, "admin.block"), - t(locale, "admin.resetPassword"), - t(locale, "admin.noUsers"), - t(locale, "server.newPassword"), - newPassPrefix, - newPassSuffix, - t(locale, "admin.resetPasswordConfirm"), - t(locale, "admin.resetPasswordMessage"), - t(locale, "admin.resetBtn"), - t(locale, "admin.deleteUser"), - delMsgPrefix, - delMsgSuffix, - t(locale, "admin.deleteBtn"), - t(locale, "admin.unblockUserTitle"), - t(locale, "admin.blockUserTitle"), - t(locale, "admin.unblockUserMessage"), - t(locale, "admin.blockUserMessage"), - t(locale, "admin.createUser"), - ) -} diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 56e0da0..74bd2f4 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -46,6 +46,7 @@ th { color:var(--muted); font-size:.8rem; letter-spacing:.06em; text-transform:u .stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1rem; margin:1rem 0; }.stat { display:grid; gap:.35rem; padding:1.2rem; }.stat strong { color:var(--accent); font-size:1.8rem; }.stat span { color:var(--muted); } .details { display:grid; grid-template-columns:minmax(150px,auto) 1fr; gap:.6rem 1rem; }.details dt { color:var(--muted); }.details dd { margin:0; overflow-wrap:anywhere; } details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.compact { margin-top:.7rem; }.compact input { min-height:36px; } +.list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; } .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.muted,.empty { color:var(--muted); }.empty { padding:2rem; text-align:center; } .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } @media (max-width:760px) { .site-header,.site-footer,.dashboard-head,.section-heading { align-items:flex-start; flex-direction:column; }.locale-form { margin-left:0; }.page-shell { width:min(100% - 1.25rem,1100px); padding:2rem 0; }.hero { padding:1.5rem; }.inline-form { min-width:0; flex-wrap:wrap; }.admin-shell { grid-template-columns:1fr; }.admin-nav { grid-template-columns:repeat(2,minmax(0,1fr)); }.admin-nav .eyebrow { grid-column:1 / -1; } } diff --git a/internal/server/web/templates/admin.html b/internal/server/web/templates/admin.html index 0618807..bc319e9 100644 --- a/internal/server/web/templates/admin.html +++ b/internal/server/web/templates/admin.html @@ -14,14 +14,16 @@
+ {{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}
{{end}} {{if eq .AdminPage "dashboard"}}

{{t .Locale "admin.overview"}}

{{t .Locale "admin.dashboard"}}

{{.Stats.Users}}{{t .Locale "admin.users"}}
{{.Stats.ActiveDevices}}{{t .Locale "admin.activeDevices"}}
{{.Stats.Vaults}}{{t .Locale "admin.vaults"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.serviceHealth"}}

{{t .Locale "admin.status"}}
{{.Health.Status}}
{{t .Locale "admin.version"}}
{{.Health.Version}} {{.Health.BuildCommit}}
{{t .Locale "admin.lastActivity"}}
{{.Stats.LastSyncAt}}
{{else if eq .AdminPage "users"}}

{{t .Locale "admin.access"}}

{{t .Locale "admin.users"}}

{{t .Locale "admin.createUser"}}
{{range .AdminUsers}}{{else}}{{end}}
{{t .Locale "field.username"}}{{t .Locale "field.email"}}{{t .Locale "admin.devices"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Username}}{{.Email}}{{.Devices}}{{if .Blocked}}{{t $.Locale "admin.blocked"}}{{else if .Confirmed}}{{t $.Locale "device.active"}}{{else}}{{t $.Locale "admin.unconfirmed"}}{{end}}
{{t $.Locale "admin.manage"}}
{{t .Locale "admin.noUsers"}}
{{else if eq .AdminPage "devices"}}

{{t .Locale "admin.access"}}

{{t .Locale "admin.devices"}}

{{range .AdminDevices}}{{else}}{{end}}
{{t .Locale "device.name"}}{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "device.lastSeen"}}{{t .Locale "device.status"}}{{t .Locale "common.actions"}}
{{.Name}}{{.User}}{{short .Vault 16}}{{.LastSeen}}{{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}}{{if not .Revoked}}
{{end}}
{{t .Locale "admin.noDevices"}}
{{else if eq .AdminPage "vaults"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.vaults"}}

{{range .Vaults}}{{else}}{{end}}
{{t .Locale "admin.user"}}{{t .Locale "device.vault"}}{{t .Locale "admin.devices"}}{{t .Locale "admin.operations"}}{{t .Locale "admin.lastActivity"}}
{{.User}}{{short .Vault 24}}{{.Devices}}{{.Operations}}{{.LastActivity}}
{{t .Locale "admin.noVaults"}}
- {{else if eq .AdminPage "storage"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.storage"}}

{{.Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}
{{.Stats.BlobBytes}}{{t .Locale "admin.blobBytes"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.retentionNote"}}

+ {{else if eq .AdminPage "storage"}}

{{t .Locale "admin.storage"}}

{{t .Locale "admin.storage"}}

{{.Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}
{{.Stats.BlobBytes}}{{t .Locale "admin.blobBytes"}}
{{.Stats.Operations}}{{t .Locale "admin.operations"}}

{{t .Locale "admin.retentionNote"}}

{{else if eq .AdminPage "audit"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.audit"}}

{{range .Audit}}{{else}}{{end}}
{{t .Locale "admin.event"}}{{t .Locale "admin.user"}}{{t .Locale "device.name"}}{{t .Locale "admin.time"}}
{{.Event}}{{.User}}{{.Device}}{{.At}}
{{t .Locale "admin.noAudit"}}
{{else if eq .AdminPage "settings"}}

{{t .Locale "admin.settings"}}

{{t .Locale "admin.smtpTitle"}}

- {{else if eq .AdminPage "diagnostics"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.status"}}
{{.Health.Status}}
{{t .Locale "admin.database"}}
{{.Health.DatabaseReachable}}
{{t .Locale "admin.blobStorage"}}
{{.Health.BlobStorageWritable}}
{{t .Locale "admin.schema"}}
{{.Health.SchemaVersion}}
{{t .Locale "admin.serverTime"}}
{{.Health.ServerTime}}
{{t .Locale "admin.healthJSON"}}
{{end}} + {{else if eq .AdminPage "diagnostics"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.diagnostics"}}

{{t .Locale "admin.status"}}
{{.Health.Status}}
{{t .Locale "admin.database"}}
{{.Health.DatabaseReachable}}
{{t .Locale "admin.blobStorage"}}
{{.Health.BlobStorageWritable}}
{{t .Locale "admin.schema"}}
{{.Health.SchemaVersion}}
{{t .Locale "admin.serverTime"}}
{{.Health.ServerTime}}
{{end}} + {{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}{{end}}
{{end}} diff --git a/internal/server/web/templates/confirm.html b/internal/server/web/templates/confirm.html new file mode 100644 index 0000000..7188bca --- /dev/null +++ b/internal/server/web/templates/confirm.html @@ -0,0 +1,2 @@ +{{define "confirm"}}{{template "layout" .}}{{end}} +{{define "content"}}

{{t .Locale "auth.account"}}

{{t .Locale "confirm.title"}}

{{t .Locale "confirm.description"}}

{{end}} diff --git a/internal/server/web_admin.go b/internal/server/web_admin.go index 8b8d23f..983cc17 100644 --- a/internal/server/web_admin.go +++ b/internal/server/web_admin.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "log" "net/http" + "strconv" "strings" "time" @@ -96,13 +97,16 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) { data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())} switch page { case "users": - data.AdminUsers, err = s.webAdminUsers() + data.List = webListFromRequest(r) + data.AdminUsers, data.List, err = s.webAdminUsers(data.List) case "devices": - data.AdminDevices, err = s.webAdminDevices() + data.List = webListFromRequest(r) + data.AdminDevices, data.List, err = s.webAdminDevices(data.List) case "vaults": data.Vaults, err = s.webVaults() case "audit": - data.Audit, err = s.webAudit() + data.List = webListFromRequest(r) + data.Audit, data.List, err = s.webAudit(data.List) case "settings": data.SMTP = s.webSMTP() } @@ -114,10 +118,61 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) { s.renderPage(w, r, "admin", data) } -func (s *Server) webAdminUsers() ([]webAdminUser, error) { - rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id) FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id GROUP BY u.id ORDER BY u.created_at DESC`) +func webListFromRequest(r *http.Request) webList { + list := webList{Query: strings.TrimSpace(r.URL.Query().Get("q")), Status: strings.TrimSpace(r.URL.Query().Get("status")), Page: 1, PerPage: 25} + if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 { + list.Page = value + } + if value, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && value > 0 && value <= 100 { + list.PerPage = value + } + return list +} + +func finishWebList(list webList, total int) webList { + list.Total = total + list.Pages = (total + list.PerPage - 1) / list.PerPage + if list.Pages == 0 { + list.Pages = 1 + } + if list.Page > list.Pages { + list.Page = list.Pages + } + if list.Page > 1 { + list.Previous = list.Page - 1 + } + if list.Page < list.Pages { + list.Next = list.Page + 1 + } + return list +} + +func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) { + where := "" + args := []interface{}{} + if list.Query != "" { + where = " WHERE (u.username LIKE ? OR u.email LIKE ?)" + like := "%" + list.Query + "%" + args = append(args, like, like) + } + if list.Status == "active" || list.Status == "blocked" || list.Status == "unconfirmed" { + condition := map[string]string{"active": "u.confirmed=1 AND u.blocked=0", "blocked": "u.blocked=1", "unconfirmed": "u.confirmed=0"}[list.Status] + if where == "" { + where = " WHERE " + condition + } else { + where += " AND " + condition + } + } + var total int + if err := s.db.QueryRow("SELECT COUNT(*) FROM server_users u"+where, args...).Scan(&total); err != nil { + return nil, list, err + } + list = finishWebList(list, total) + queryArgs := append([]interface{}{}, args...) + queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) + rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id) FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { - return nil, err + return nil, list, err } defer rows.Close() var out []webAdminUser @@ -125,19 +180,41 @@ func (s *Server) webAdminUsers() ([]webAdminUser, error) { var u webAdminUser var confirmed, blocked int if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices); err != nil { - return nil, err + return nil, list, err } u.Confirmed = confirmed != 0 u.Blocked = blocked != 0 out = append(out, u) } - return out, rows.Err() + return out, list, rows.Err() } -func (s *Server) webAdminDevices() ([]webAdminDevice, error) { - rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id ORDER BY d.created_at DESC`) +func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) { + where := "" + args := []interface{}{} + if list.Query != "" { + where = " WHERE (d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)" + like := "%" + list.Query + "%" + args = append(args, like, like, like) + } + if list.Status == "active" || list.Status == "revoked" { + condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status] + if where == "" { + where = " WHERE " + condition + } else { + where += " AND " + condition + } + } + var total int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where, args...).Scan(&total); err != nil { + return nil, list, err + } + list = finishWebList(list, total) + queryArgs := append([]interface{}{}, args...) + queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) + rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { - return nil, err + return nil, list, err } defer rows.Close() var out []webAdminDevice @@ -145,7 +222,7 @@ func (s *Server) webAdminDevices() ([]webAdminDevice, error) { var d webAdminDevice var revoked string if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastSeen, &revoked, &d.CreatedAt); err != nil { - return nil, err + return nil, list, err } d.Revoked = revoked != "" if d.LastSeen == "" { @@ -153,7 +230,7 @@ func (s *Server) webAdminDevices() ([]webAdminDevice, error) { } out = append(out, d) } - return out, rows.Err() + return out, list, rows.Err() } func (s *Server) webVaults() ([]webVault, error) { @@ -173,21 +250,35 @@ func (s *Server) webVaults() ([]webVault, error) { return out, rows.Err() } -func (s *Server) webAudit() ([]webAudit, error) { - rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log ORDER BY id DESC LIMIT 100`) +func (s *Server) webAudit(list webList) ([]webAudit, webList, error) { + where := "" + args := []interface{}{} + if list.Query != "" { + where = " WHERE (event_type LIKE ? OR user_id LIKE ? OR device_id LIKE ?)" + like := "%" + list.Query + "%" + args = append(args, like, like, like) + } + var total int + if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log"+where, args...).Scan(&total); err != nil { + return nil, list, err + } + list = finishWebList(list, total) + queryArgs := append([]interface{}{}, args...) + queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) + rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { - return nil, err + return nil, list, err } defer rows.Close() var out []webAudit for rows.Next() { var a webAudit if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil { - return nil, err + return nil, list, err } out = append(out, a) } - return out, rows.Err() + return out, list, rows.Err() } func (s *Server) webSMTP() webSMTP { @@ -370,6 +461,17 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) { } s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator") http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) + case "cleanup": + if !s.adminReauth(r, session.SubjectID) { + s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage") + return + } + if err := s.CleanupRetention(time.Now().UTC()); err != nil { + jsonInternalError(w, err) + return + } + s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator") + http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) default: s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard") } @@ -392,3 +494,35 @@ func (s *Server) handleAdminWebLogout(w http.ResponseWriter, r *http.Request) { s.clearSessionCookies(w, r, sessionScopeAdmin) http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } + +// handleAdminDiagnosticsJSON is intentionally a separate, authenticated +// download: it contains operational state but never paths, credentials, +// tokens, payloads, or user content. +func (s *Server) handleAdminDiagnosticsJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + if !s.requireAdminCookie(w, r) { + return + } + stats, err := s.Stats(r.Context()) + if err != nil { + jsonInternalError(w, err) + return + } + jsonOK(w, map[string]interface{}{ + "health": s.healthStatus(r.Context()), + "stats": stats, + "limits": map[string]interface{}{ + "max_json_body": s.cfg.Limits.MaxJSONBody, + "max_push_operations": s.cfg.Limits.MaxPushOperations, + "max_pull_page": s.cfg.Limits.MaxPullPage, + "max_blob_bytes": s.cfg.Limits.MaxBlobBytes, + }, + "web": map[string]interface{}{ + "default_locale": s.cfg.Web.DefaultLocale, + "registration_allowed": s.cfg.Web.AllowRegistration, + }, + }) +} diff --git a/internal/server/web_locale_test.go b/internal/server/web_locale_test.go index 998ffd7..bffc93a 100644 --- a/internal/server/web_locale_test.go +++ b/internal/server/web_locale_test.go @@ -4,6 +4,7 @@ import ( "io/fs" "net/http" "net/http/httptest" + "regexp" "strings" "testing" ) @@ -55,6 +56,27 @@ func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T } } +func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) { + entries, err := fs.Glob(webFS, "web/templates/*.html") + if err != nil { + t.Fatal(err) + } + keyPattern := regexp.MustCompile(`t\s+\$?\.Locale\s+"([^"]+)"`) + for _, name := range entries { + body, err := webFS.ReadFile(name) + if err != nil { + t.Fatal(err) + } + for _, match := range keyPattern.FindAllStringSubmatch(string(body), -1) { + for _, locale := range []string{"ru", "en"} { + if _, ok := _translations[locale][match[1]]; !ok { + t.Fatalf("%s references missing %s translation key %q", name, locale, match[1]) + } + } + } + } +} + func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) { s, err := newTestServer(t) if err != nil { @@ -80,6 +102,59 @@ func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) { } } +func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + for _, locale := range []string{"ru", "en"} { + for _, path := range []string{"/", "/login", "/register", "/forgot", "/admin/login"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale}) + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("%s %s = %d", locale, path, res.Code) + } + if !strings.Contains(res.Body.String(), ``) { + t.Fatalf("%s %s has wrong document language", locale, path) + } + } + } +} + +func TestWebSessionScopesDoNotCrossAuthorizePages(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + adminToken, _, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + userToken, _, err := s.createSession(sessionScopeUser, "user") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ path, cookie string }{{"/dashboard", adminToken}, {"/admin/dashboard", userToken}} { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + if tc.path == "/dashboard" { + req.AddCookie(&http.Cookie{Name: "admin_session", Value: tc.cookie}) + } else { + req.AddCookie(&http.Cookie{Name: "user_session", Value: tc.cookie}) + } + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusFound { + t.Fatalf("%s with wrong scope = %d", tc.path, res.Code) + } + } +} + func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) { s, err := newTestServer(t) if err != nil { @@ -154,6 +229,65 @@ func TestAdminLoginUsesSharedTemplateAndAdminRootRedirects(t *testing.T) { } } +func TestDiagnosticsDownloadRequiresAdminAndDoesNotExposePaths(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.SetupRoutes() + unauthorized := httptest.NewRecorder() + s.Handler().ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/admin/diagnostics.json", nil)) + if unauthorized.Code != http.StatusFound { + t.Fatalf("unauthorized diagnostics = %d", unauthorized.Code) + } + token, _, err := s.createSession(sessionScopeAdmin, "admin") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/admin/diagnostics.json", nil) + req.AddCookie(&http.Cookie{Name: "admin_session", Value: token}) + res := httptest.NewRecorder() + s.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("diagnostics = %d: %s", res.Code, res.Body.String()) + } + if strings.Contains(res.Body.String(), s.dbPath) || strings.Contains(res.Body.String(), s.blobsDir) { + t.Fatalf("diagnostics leaked internal path: %s", res.Body.String()) + } +} + +func TestAdminUserListSearchStatusAndPagination(t *testing.T) { + s, err := newTestServer(t) + if err != nil { + t.Fatal(err) + } + defer s.Close() + for i, username := range []string{"alice", "alina", "blocked-user", "bob"} { + blocked := 0 + if username == "blocked-user" { + blocked = 1 + } + if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,blocked,created_at) VALUES (?, ?, ?, 'hash', 1, ?, '2026-01-01T00:00:00Z')", strconvItoa(i), username, username+"@example.test", blocked); err != nil { + t.Fatal(err) + } + } + items, list, err := s.webAdminUsers(webList{Query: "ali", Page: 1, PerPage: 1}) + if err != nil { + t.Fatal(err) + } + if list.Total != 2 || list.Pages != 2 || len(items) != 1 || items[0].Username != "alice" { + t.Fatalf("search pagination: list=%+v items=%+v", list, items) + } + items, list, err = s.webAdminUsers(webList{Status: "blocked", Page: 1, PerPage: 25}) + if err != nil { + t.Fatal(err) + } + if list.Total != 1 || len(items) != 1 || !items[0].Blocked { + t.Fatalf("status filter: list=%+v items=%+v", list, items) + } +} + func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) { cfg := DefaultConfig() cfg.Web.DefaultLocale = "en" diff --git a/internal/server/web_render.go b/internal/server/web_render.go index 89635ae..dc4aab0 100644 --- a/internal/server/web_render.go +++ b/internal/server/web_render.go @@ -48,6 +48,7 @@ type webPage struct { Vaults []webVault Audit []webAudit SMTP webSMTP + List webList } type webAdminUser struct { @@ -67,6 +68,17 @@ type webVault struct { type webAudit struct{ Event, User, Device, At string } type webSMTP struct{ Host, Port, User, Security, From, ServerURL string } +type webList struct { + Query string + Status string + Page int + PerPage int + Total int + Pages int + Previous int + Next int +} + type webDevice struct { ID string Name string @@ -93,7 +105,7 @@ func newWebRenderer() (*webRenderer, error) { return nil, err } renderer := &webRenderer{templates: make(map[string]*template.Template)} - for _, page := range []string{"home", "login", "register", "forgot", "reset", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user"} { + for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user"} { clone, err := layout.Clone() if err != nil { return nil, err @@ -217,6 +229,15 @@ func (s *Server) handleResetDone(w http.ResponseWriter, r *http.Request) { s.renderPage(w, r, "message", webPage{Title: "reset.doneTitle", Heading: "reset.doneTitle", Message: "reset.doneMessage", BackURL: "/login"}) } +func (s *Server) handleConfirmResult(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + w.Header().Set("Cache-Control", "no-store") + s.renderPage(w, r, "message", webPage{Title: "confirm.resultTitle", Heading: "confirm.resultTitle", Message: "confirm.resultMessage", BackURL: "/login"}) +} + func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; script-src 'self'; style-src 'self'")