refactor(web): remove legacy sync server templates
This commit is contained in:
parent
4b78f30a61
commit
f82d3b0224
|
|
@ -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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin Dashboard</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
|
||||
h1{color:#4ecca3}table{width:100%;border-collapse:collapse;margin:1rem 0}
|
||||
th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
|
||||
td{padding:0.5rem;border-bottom:1px solid #0f3460}.stat{display:inline-block;background:#16213e;padding:1rem 1.5rem;border-radius:8px;margin:0.5rem;border:1px solid #0f3460}
|
||||
.stat-num{font-size:1.5rem;color:#4ecca3;font-weight:600}.stat-label{color:#a0a0b8;font-size:0.85rem}
|
||||
a{color:#4ecca3}</style></head><body>
|
||||
<h1>Verstak Sync Server — Admin</h1>
|
||||
<div class="stat"><div class="stat-num">` + intToStr(userCount) + `</div><div class="stat-label">Users</div></div>
|
||||
<div class="stat"><div class="stat-num">` + intToStr(deviceCount) + `</div><div class="stat-label">Devices</div></div>
|
||||
<div class="stat"><div class="stat-num">` + intToStr(opsCount) + `</div><div class="stat-label">Sync Ops</div></div>
|
||||
<h2><a href="/admin/users">Users</a> | <a href="/admin/devices">Devices</a> | <a href="/api/v1/health">Health</a></h2>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Users</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
|
||||
h1{color:#4ecca3}table{width:100%;border-collapse:collapse}th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
|
||||
td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head><body>
|
||||
<h1>Users <a href="/admin/dashboard">← Dashboard</a></h1>
|
||||
<table><tr><th>Username</th><th>Email</th><th>Confirmed</th><th>Blocked</th><th>Created</th></tr>`))
|
||||
|
||||
for _, u := range users {
|
||||
confirmed := "✅"
|
||||
if u["confirmed"].(int) == 0 {
|
||||
confirmed = "❌"
|
||||
}
|
||||
blocked := ""
|
||||
if u["blocked"].(int) != 0 {
|
||||
blocked = "🚫"
|
||||
}
|
||||
w.Write([]byte(`<tr><td>` + html.EscapeString(u["username"].(string)) + `</td><td>` + html.EscapeString(u["email"].(string)) +
|
||||
`</td><td>` + confirmed + `</td><td>` + blocked + `</td><td>` + html.EscapeString(u["created_at"].(string)) + `</td></tr>`))
|
||||
}
|
||||
w.Write([]byte(`</table></body></html>`))
|
||||
}
|
||||
|
||||
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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Devices</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
|
||||
h1{color:#4ecca3}table{width:100%;border-collapse:collapse}th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
|
||||
td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head><body>
|
||||
<h1>Devices <a href="/admin/dashboard">← Dashboard</a></h1>
|
||||
<table><tr><th>Name</th><th>ID</th><th>Version</th><th>Last Seen</th><th>Revoked</th><th>Created</th></tr>`))
|
||||
|
||||
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(`<tr><td>` + html.EscapeString(name) + `</td><td style="font-family:monospace;font-size:0.8em">` + html.EscapeString(id) +
|
||||
`</td><td>` + html.EscapeString(clientVer) + `</td><td>` + html.EscapeString(lastSeen) + `</td><td>` + html.EscapeString(revokedAt) + `</td><td>` + html.EscapeString(createdAt) + `</td></tr>`))
|
||||
}
|
||||
w.Write([]byte(`</table></body></html>`))
|
||||
}
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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(`<form method="POST"><input type="hidden" name="token" value="` + html.EscapeString(tokenStr) + `"><button>Confirm email</button></form>`))
|
||||
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 == "" {
|
||||
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 {
|
||||
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) {
|
||||
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)
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -64,23 +64,6 @@ func TestConfigSetAdmin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminDashboardSMTPSecuritySelectUsesApplicationStyles(t *testing.T) {
|
||||
html := adminDashboardHTML("en", 0, 0, "", "", "", "", "starttls", "")
|
||||
|
||||
for _, expected := range []string{
|
||||
`<select name="smtp_security" class="form-select">`,
|
||||
`.form-select{`,
|
||||
`appearance:none`,
|
||||
`background-image:linear-gradient`,
|
||||
`.form-select option{background:#13131f;color:#e4e4ef}`,
|
||||
`.form-select:focus{outline:none;border-color:#6366f1`,
|
||||
} {
|
||||
if !strings.Contains(html, expected) {
|
||||
t.Errorf("SMTP security select is missing application styling %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFacingServerErrorsDoNotExposeInternalDetails(t *testing.T) {
|
||||
for _, name := range []string{"handlers_auth.go", "handlers_api.go", "handlers_admin.go", "handlers_web_user.go"} {
|
||||
source, err := os.ReadFile(name)
|
||||
|
|
|
|||
|
|
@ -1,826 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func userRegisterHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
|
||||
</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="email" name="email" required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required minlength="8" maxlength="256">
|
||||
<button>%s</button>
|
||||
<p>%s <a href="/login">%s</a></p>
|
||||
</form>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Verstak Sync</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required>
|
||||
<button>%s</button>
|
||||
<div class="links">
|
||||
<a href="/forgot">%s</a><br>
|
||||
<a href="/register">%s</a> · <a href="/admin/login">%s</a>
|
||||
</div>
|
||||
</form>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px;text-align:center}
|
||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 20px}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.emailConfirmed"),
|
||||
t(locale, "server.emailConfirmed"),
|
||||
t(locale, "server.emailConfirmedMessage"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func registrationOKHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.registerTitle"),
|
||||
t(locale, "server.registrationSuccess"),
|
||||
t(locale, "server.registrationAutoMessage"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func forgotPasswordHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:18px;margin:0 0 8px;text-align:center}
|
||||
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<label>%s</label>
|
||||
<input type="email" name="email" autofocus required>
|
||||
<button>%s</button>
|
||||
<div class="links"><a href="/login">%s</a></div>
|
||||
</form>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.emailSentTitle"),
|
||||
t(locale, "server.emailSent"),
|
||||
t(locale, "server.emailSentMessage"),
|
||||
t(locale, "server.goHome"),
|
||||
)
|
||||
}
|
||||
|
||||
func resetPasswordHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:18px;margin:0 0 20px;text-align:center}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<input type="hidden" name="token" value="{TOKEN}">
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" minlength="8" maxlength="256" required autofocus>
|
||||
<label>%s</label>
|
||||
<input type="password" name="confirm" minlength="8" maxlength="256" required>
|
||||
<button style="margin-top:8px">%s</button>
|
||||
</form>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%[1]s</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
|
||||
a{color:#6366f1}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
h2{margin-top:24px;font-size:16px}
|
||||
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
|
||||
table{width:100%%;border-collapse:collapse;margin-top:8px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
||||
th{font-size:12px;color:#888;text-transform:uppercase}
|
||||
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
|
||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
||||
.btn:hover{background:#222233}
|
||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
||||
.btn-danger:hover{background:#3a2222}
|
||||
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
|
||||
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
|
||||
input:focus{outline:none;border-color:#6366f1}
|
||||
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
|
||||
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
||||
.form-row input{flex:1}
|
||||
.form-select{font-family:inherit;font-size:14px;padding:8px 32px 8px 12px;border:1px solid #2a2a3c;background-color:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box;appearance:none;background-image:linear-gradient(45deg,transparent 50%%,#8b93aa 50%%),linear-gradient(135deg,#8b93aa 50%%,transparent 50%%);background-position:calc(100%% - 16px) 50%%,calc(100%% - 11px) 50%%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
|
||||
.form-select option{background:#13131f;color:#e4e4ef}
|
||||
.form-select:focus{outline:none;border-color:#6366f1;box-shadow:0 0 0 2px rgba(99,102,241,.24)}
|
||||
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
|
||||
.modal h2{margin-top:0}
|
||||
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
||||
.modal-close:hover{color:#e4e4ef}
|
||||
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>Verstak Sync Server</h1>
|
||||
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
||||
<div class="stat" style="margin:0"><strong>%[2]s</strong> <span id="dev-count">%[40]d</span></div>
|
||||
<div class="stat" style="margin:0"><strong>%[3]s</strong> <span id="op-count">%[41]d</span></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openSMTP()">%[15]s</button>
|
||||
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">%[16]s</button></a>
|
||||
<button class="btn" onclick="openHealth()">%[17]s</button>
|
||||
</div>
|
||||
|
||||
<h2>%[4]s</h2>
|
||||
<div id="devices"></div>
|
||||
<script>
|
||||
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
|
||||
const div=document.getElementById('devices')
|
||||
if(!devices.length){div.innerHTML='<p>%[5]s</p>';return}
|
||||
div.innerHTML='<table><tr><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th><th>%[9]s</th><th>%[10]s</th><th></th></tr>'+
|
||||
devices.map(d=>{
|
||||
var status=d.revoked_at?'<span style="color:#ff6b6b">%[12]s</span>':'<span style="color:#34d399">%[11]s</span>'
|
||||
var ls=d.last_seen||'\u2014'
|
||||
var revBtn=''
|
||||
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">%[13]s</button>'
|
||||
return '<tr><td>'+d.name+'</td><td>'+(d.user||'\u2014')+'</td><td>'+(d.client_version||'\u2014')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
|
||||
}).join('')+'</table>'
|
||||
document.getElementById('dev-count').textContent=devices.length
|
||||
})
|
||||
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
|
||||
document.getElementById('op-count').textContent=stats.ops||'0'
|
||||
})
|
||||
function revokeDevice(id){
|
||||
if(!confirm('%[31]s'))return
|
||||
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
|
||||
}
|
||||
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
|
||||
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
|
||||
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='%[14]s';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
|
||||
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
|
||||
function testSMTP(){
|
||||
var f=document.querySelector('#smtp-modal form')
|
||||
var fd=new FormData(f)
|
||||
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
|
||||
var r=document.getElementById('smtp-test-result')
|
||||
r.textContent='%[29]s';r.style.color='#888'
|
||||
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
|
||||
r.textContent=d.ok?'%[30]s':'\u2717 '+d.error
|
||||
r.style.color=d.ok?'#4ade80':'#ff6b6b'
|
||||
}).catch(function(e){r.textContent='\u2717 '+e;r.style.color='#ff6b6b'})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeSMTP()">×</button>
|
||||
<h2>%[28]s</h2>
|
||||
<form action="/admin/api/smtp" method="POST">
|
||||
<div class="form-row"><label>%[18]s</label><input name="smtp_host" value="%[32]s" placeholder="smtp.example.com"></div>
|
||||
<div class="form-row"><label>%[19]s</label><input name="smtp_port" value="%[33]s" placeholder="587"></div>
|
||||
<div class="form-row"><label>%[20]s</label><select name="smtp_security" class="form-select">
|
||||
<option value="starttls"%[34]s>STARTTLS</option>
|
||||
<option value="tls"%[35]s>TLS</option>
|
||||
<option value="none"%[36]s>%[21]s</option>
|
||||
</select></div>
|
||||
<div class="form-row"><label>%[22]s</label><input name="smtp_user" value="%[37]s" placeholder="user@example.com"></div>
|
||||
<div class="form-row"><label>%[23]s</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
|
||||
<div class="form-row"><label>%[24]s</label><input name="smtp_from" value="%[38]s" placeholder="noreply@example.com"></div>
|
||||
<div class="form-row"><label>%[25]s</label><input name="server_url" value="%[39]s" placeholder="https://example.com:47732"></div>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
|
||||
<button class="btn btn-primary">%[26]s</button>
|
||||
<button class="btn" type="button" onclick="testSMTP()">%[27]s</button>
|
||||
<span id="smtp-test-result" style="font-size:12px"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeHealth()">×</button>
|
||||
<h2>%[17]s</h2>
|
||||
<pre id="health-result">%[14]s</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %[1]s</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
h2{margin-top:24px;font-size:16px}
|
||||
table{width:100%%;border-collapse:collapse;margin-top:8px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
||||
th{font-size:12px;color:#888;text-transform:uppercase}
|
||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
||||
.btn:hover{background:#222233}
|
||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
||||
.btn-danger:hover{background:#3a2222}
|
||||
.btn-sm{padding:2px 8px;font-size:11px}
|
||||
.top{display:flex;justify-content:space-between;align-items:center}
|
||||
a{color:#6366f1}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="top">
|
||||
<h1>Verstak Sync</h1>
|
||||
<span>%[1]s · <form action="/logout" method="POST" style="display:inline"><input type="hidden" name="csrf_token" value="%[14]s"><button type="submit" style="border:0;background:none;color:#6366f1;padding:0;cursor:pointer">%[2]s</button></form></span>
|
||||
</div>
|
||||
<h2>%[3]s</h2>
|
||||
<table><tr><th>%[4]s</th><th>%[5]s</th><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th></tr>%[9]s</table>
|
||||
|
||||
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
|
||||
<h2 style="margin-top:0">%[10]s</h2>
|
||||
<p style="font-size:13px;color:#888">%[11]s</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function revokeDevice(id){
|
||||
if(!confirm('%[12]s'))return
|
||||
var pw=prompt('%[13]s')
|
||||
if(!pw)return
|
||||
fetch('/api/v1/user/devices/'+encodeURIComponent(id)+'/revoke',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':'%[14]s'},body:JSON.stringify({password:pw})}).then(function(r){return r.json()}).then(function(d){
|
||||
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%[1]s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
|
||||
</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="%[8]s">
|
||||
<h1>%[2]s</h1>
|
||||
<label>%[3]s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%[4]s</label>
|
||||
<input type="email" name="email" required>
|
||||
<label>%[5]s</label>
|
||||
<input type="password" name="password" required minlength="8" maxlength="256">
|
||||
<button>%[6]s</button>
|
||||
<p><a href="/admin/users">%[7]s</a></p>
|
||||
</form>
|
||||
</body></html>`,
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;text-align:center;max-width:360px}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#ff6b6b}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 16px}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="%s">%s</a>
|
||||
</div>
|
||||
</body></html>`, 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(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%[1]s</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
|
||||
a{color:#6366f1}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
table{width:100%%;border-collapse:collapse;margin-top:12px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
||||
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
|
||||
th:hover{color:#b0b0c0}
|
||||
th.sorted{color:#6366f1}
|
||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
||||
.btn:hover{background:#222233}
|
||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
||||
.btn-danger:hover{background:#3a2222}
|
||||
.btn-sm{padding:2px 8px;font-size:11px}
|
||||
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;box-sizing:border-box}
|
||||
input:focus{outline:none;border-color:#6366f1}
|
||||
.toolbar{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap;align-items:center}
|
||||
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;justify-content:center}
|
||||
.pagination span{padding:4px 8px;font-size:12px;color:#888}
|
||||
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
|
||||
.badge-green{background:#064e3b;color:#34d399}
|
||||
.badge-red{background:#4a2222;color:#ff6b6b}
|
||||
.badge-yellow{background:#4a3e00;color:#fbbf24}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:400px;max-width:90vw;position:relative}
|
||||
.modal h2{margin-top:0;font-size:16px}
|
||||
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
||||
.modal-close:hover{color:#e4e4ef}
|
||||
.form-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
|
||||
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
||||
.form-row input{flex:1}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>%[2]s</h1>
|
||||
<p><a href="/admin/dashboard">%[3]s</a></p>
|
||||
|
||||
<div class="toolbar">
|
||||
<input id="filter-input" placeholder="%[4]s" style="width:200px" onkeyup="loadUsers()">
|
||||
<a href="/admin/create-user" style="text-decoration:none"><button class="btn btn-primary" type="button">%[39]s</button></a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th onclick="sortBy('username')">%[5]s <span id="s-username"></span></th>
|
||||
<th onclick="sortBy('email')">%[6]s <span id="s-email"></span></th>
|
||||
<th onclick="sortBy('confirmed')">%[7]s <span id="s-confirmed"></span></th>
|
||||
<th onclick="sortBy('devices')">%[8]s <span id="s-devices"></span></th>
|
||||
<th onclick="sortBy('last_seen')">%[9]s <span id="s-last_seen"></span></th>
|
||||
<th>%[10]s</th>
|
||||
</tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" id="pagination"></div>
|
||||
|
||||
<div id="confirm-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeConfirm()">×</button>
|
||||
<h2 id="confirm-title">%[11]s</h2>
|
||||
<p id="confirm-text"></p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
||||
<button class="btn" onclick="closeConfirm()">%[12]s</button>
|
||||
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">%[13]s</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeEdit()">×</button>
|
||||
<h2>%[14]s</h2>
|
||||
<div class="form-row"><label>%[15]s</label><input id="edit-username"></div>
|
||||
<div class="form-row"><label>%[16]s</label><input id="edit-email" type="email"></div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
||||
<button class="btn" onclick="closeEdit()">%[17]s</button>
|
||||
<button class="btn btn-primary" onclick="saveEdit()">%[18]s</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal" style="width:320px">
|
||||
<button class="modal-close" onclick="closeResult()">×</button>
|
||||
<h2 id="result-title">%[19]s</h2>
|
||||
<p id="result-text" style="white-space:pre-wrap"></p>
|
||||
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">%[20]s</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var currentPage=1,currentSort='',currentOrder='',editUserId='',pendingAction=''
|
||||
|
||||
function loadUsers(){
|
||||
var f=document.getElementById('filter-input').value
|
||||
var u='/admin/api/users?page='+currentPage+'&per_page=20&filter='+encodeURIComponent(f)
|
||||
if(currentSort){u+='&sort='+currentSort+'&order='+currentOrder}
|
||||
fetch(u).then(function(r){return r.json()}).then(function(d){
|
||||
var tbody=document.getElementById('users-tbody')
|
||||
tbody.innerHTML=''
|
||||
d.users.forEach(function(u){
|
||||
var status=u.confirmed?'<span class="badge badge-green">%[21]s</span>':'<span class="badge badge-yellow">%[22]s</span>'
|
||||
if(u.blocked){status='<span class="badge badge-red">%[23]s</span>'}
|
||||
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
|
||||
var blockText=u.blocked?'%[24]s':'%[25]s'
|
||||
var tr=document.createElement('tr')
|
||||
tr.innerHTML='<td>'+esc(u.username)+'</td><td>'+esc(u.email)+'</td><td>'+status+'</td><td>'+u.devices+'</td><td>'+lastSeen+'</td>'+
|
||||
'<td><button class="btn btn-sm" onclick="editUser(\''+u.id+'\',\''+escJS(u.username)+'\',\''+escJS(u.email)+'\')">✎</button> '+
|
||||
'<button class="btn btn-sm" onclick="askBlock(\''+u.id+'\','+u.blocked+')">'+blockText+'</button> '+
|
||||
'<button class="btn btn-sm" onclick="askReset(\''+u.id+'\')">%[26]s</button> '+
|
||||
'<button class="btn btn-sm btn-danger" onclick="askDelete(\''+u.id+'\',\''+escJS(u.username)+'\')">✕</button></td>'
|
||||
tbody.appendChild(tr)
|
||||
})
|
||||
if(!d.users.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:#666">%[27]s</td></tr>'}
|
||||
var totalPages=Math.ceil(d.total/d.per_page)
|
||||
var pag=document.getElementById('pagination')
|
||||
pag.innerHTML=''
|
||||
if(totalPages>1){
|
||||
var prev=document.createElement('button')
|
||||
prev.className='btn btn-sm';prev.textContent='←';prev.onclick=function(){if(currentPage>1){currentPage--;loadUsers()}}
|
||||
pag.appendChild(prev)
|
||||
var s=document.createElement('span')
|
||||
s.textContent=d.page+' / '+totalPages
|
||||
pag.appendChild(s)
|
||||
var next=document.createElement('button')
|
||||
next.className='btn btn-sm';next.textContent='→';next.onclick=function(){if(currentPage<totalPages){currentPage++;loadUsers()}}
|
||||
pag.appendChild(next)
|
||||
}
|
||||
})
|
||||
}
|
||||
function sortBy(col){
|
||||
if(currentSort===col){currentOrder=currentOrder==='asc'?'desc':'asc'}
|
||||
else{currentSort=col;currentOrder='asc'}
|
||||
document.querySelectorAll('th').forEach(function(th){th.classList.remove('sorted')})
|
||||
var el=document.getElementById('s-'+col)
|
||||
if(el){el.parentElement.classList.add('sorted');el.textContent=currentOrder==='asc'?' ▲':' ▼'}
|
||||
loadUsers()
|
||||
}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'"')}
|
||||
function editUser(id,username,email){
|
||||
editUserId=id;document.getElementById('edit-username').value=username;document.getElementById('edit-email').value=email;document.getElementById('edit-modal').style.display='flex'}
|
||||
function closeEdit(){document.getElementById('edit-modal').style.display='none'}
|
||||
function saveEdit(){
|
||||
var un=document.getElementById('edit-username').value,em=document.getElementById('edit-email').value
|
||||
if(!un||!em)return
|
||||
fetch('/admin/api/users/'+editUserId+'/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,email:em})}).then(function(r){return r.json()}).then(function(d){closeEdit();if(d.status==='ok')loadUsers()})
|
||||
}
|
||||
function askBlock(id,blocked){
|
||||
pendingAction=function(){fetch('/admin/api/users/'+id+'/block',{method:'POST'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
|
||||
document.getElementById('confirm-title').textContent=blocked?'%[35]s':'%[36]s'
|
||||
document.getElementById('confirm-text').textContent=blocked?'%[37]s':'%[38]s'
|
||||
document.getElementById('confirm-btn').textContent=blocked?'%[24]s':'%[25]s'
|
||||
document.getElementById('confirm-modal').style.display='flex'}
|
||||
function askReset(id){
|
||||
pendingAction=function(){
|
||||
fetch('/admin/api/users/'+id+'/reset-password',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
|
||||
document.getElementById('confirm-modal').style.display='none'
|
||||
document.getElementById('result-title').textContent='%[28]s'
|
||||
document.getElementById('result-text').textContent='%[29]s' + d.new_password + '%[30]s'
|
||||
document.getElementById('result-modal').style.display='flex'})}
|
||||
document.getElementById('confirm-title').textContent='%[31]s'
|
||||
document.getElementById('confirm-text').textContent='%[32]s'
|
||||
document.getElementById('confirm-btn').textContent='%[33]s'
|
||||
document.getElementById('confirm-modal').style.display='flex'}
|
||||
function askDelete(id,username){
|
||||
pendingAction=function(){fetch('/admin/api/users/'+id,{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
|
||||
document.getElementById('confirm-title').textContent='%[34]s'
|
||||
document.getElementById('confirm-text').textContent='%[35]s' + username + '%[36]s'
|
||||
document.getElementById('confirm-btn').textContent='%[37]s'
|
||||
document.getElementById('confirm-modal').style.display='flex'}
|
||||
function closeConfirm(){document.getElementById('confirm-modal').style.display='none';pendingAction=''}
|
||||
function confirmAction(){if(pendingAction){pendingAction();pendingAction=''}}
|
||||
function closeResult(){document.getElementById('result-modal').style.display='none'}
|
||||
loadUsers()
|
||||
</script>
|
||||
</body></html>`,
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -14,14 +14,16 @@
|
|||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
|
||||
</aside>
|
||||
<div class="admin-content">
|
||||
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>{{end}}
|
||||
{{if eq .AdminPage "dashboard"}}<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.Stats.LastSyncAt}}</dd></dl></section>
|
||||
{{else if eq .AdminPage "users"}}<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button secondary" type="submit">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><details><summary>{{t $.Locale "admin.manage"}}</summary><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.newPassword"}}<input name="new_password" type="password" required></label><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.resetPassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button danger" type="submit">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "devices"}}<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="6" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "vaults"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono">{{short .Vault 24}}</td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "storage"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{.Stats.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><p class="muted">{{t .Locale "admin.retentionNote"}}</p>
|
||||
{{else if eq .AdminPage "storage"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{.Stats.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><input name="password" type="password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit">{{t .Locale "admin.runCleanup"}}</button></form>
|
||||
{{else if eq .AdminPage "audit"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{.Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.At}}</td></tr>{{else}}<tr><td colspan="4" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "settings"}}<p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.smtpTitle"}}</h1><section class="card panel"><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp"><label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}"></label><label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" value="{{.SMTP.Port}}"></label><label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}"></label><label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label><label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>STARTTLS</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>TLS</option></select></label><label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" value="{{.SMTP.From}}"></label><label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" value="{{.SMTP.ServerURL}}"></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button></form></section>
|
||||
{{else if eq .AdminPage "diagnostics"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{.Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{.Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{.Health.ServerTime}}</dd></dl><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a></section>{{end}}
|
||||
{{else if eq .AdminPage "diagnostics"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{.Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{.Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{.Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>{{end}}
|
||||
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&status={{.List.Status}}&page={{.List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&status={{.List.Status}}&page={{.List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
{{define "confirm"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(), `<html lang="`+locale+`">`) {
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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'")
|
||||
|
|
|
|||
Loading…
Reference in New Issue