feat: add user creation with manual password in admin UI and API
This commit is contained in:
parent
cec2305b15
commit
c9b35295cb
|
|
@ -4,9 +4,12 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -169,6 +172,427 @@ func intToStr(n int) string {
|
||||||
return strings.Trim(string(b), "\"")
|
return strings.Trim(string(b), "\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var opsCount int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
||||||
|
jsonOK(w, map[string]int{"ops": opsCount})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != "POST" {
|
||||||
|
jsonErr(w, 405, "POST required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Host string `json:"smtp_host"`
|
||||||
|
Port string `json:"smtp_port"`
|
||||||
|
User string `json:"smtp_user"`
|
||||||
|
Pass string `json:"smtp_pass"`
|
||||||
|
Security string `json:"smtp_security"`
|
||||||
|
From string `json:"smtp_from"`
|
||||||
|
To string `json:"test_to"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonErr(w, 400, "bad json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host := req.Host
|
||||||
|
port := req.Port
|
||||||
|
user := req.User
|
||||||
|
pass := req.Pass
|
||||||
|
security := req.Security
|
||||||
|
from := req.From
|
||||||
|
to := req.To
|
||||||
|
if to == "" {
|
||||||
|
to = from
|
||||||
|
}
|
||||||
|
if host == "" || port == "" || from == "" {
|
||||||
|
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
|
||||||
|
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]interface{}{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAPIDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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,
|
||||||
|
COALESCE(u.username,'')
|
||||||
|
FROM server_devices d
|
||||||
|
LEFT JOIN server_users u ON u.id = d.user_id
|
||||||
|
ORDER BY d.created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type devDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ClientVersion string `json:"client_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
RevokedAt string `json:"revoked_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
User string `json:"user"`
|
||||||
|
}
|
||||||
|
var out []devDTO
|
||||||
|
for rows.Next() {
|
||||||
|
var d devDTO
|
||||||
|
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []map[string]string
|
||||||
|
for rows.Next() {
|
||||||
|
var id, name, key string
|
||||||
|
rows.Scan(&id, &name, &key)
|
||||||
|
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
default:
|
||||||
|
jsonErr(w, 405, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != "POST" {
|
||||||
|
jsonErr(w, 405, "POST required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
jsonErr(w, 400, "bad form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
|
||||||
|
val := r.FormValue(key)
|
||||||
|
if val != "" {
|
||||||
|
s.smtpSet(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != "DELETE" {
|
||||||
|
jsonErr(w, 405, "DELETE required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
|
||||||
|
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
|
||||||
|
jsonOK(w, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := r.URL.Query().Get("filter")
|
||||||
|
sort := r.URL.Query().Get("sort")
|
||||||
|
order := r.URL.Query().Get("order")
|
||||||
|
page := 1
|
||||||
|
perPage := 20
|
||||||
|
if v := r.URL.Query().Get("page"); v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &page)
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("per_page"); v != "" {
|
||||||
|
fmt.Sscanf(v, "%d", &perPage)
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 100 {
|
||||||
|
perPage = 20
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
var args []interface{}
|
||||||
|
if filter != "" {
|
||||||
|
where = " WHERE u.username LIKE ?"
|
||||||
|
args = append(args, "%"+filter+"%")
|
||||||
|
}
|
||||||
|
validSorts := map[string]string{
|
||||||
|
"username": "u.username",
|
||||||
|
"email": "u.email",
|
||||||
|
"confirmed": "u.confirmed",
|
||||||
|
"blocked": "u.blocked",
|
||||||
|
"created_at": "u.created_at",
|
||||||
|
"last_seen": "u.last_seen",
|
||||||
|
"devices": "devices",
|
||||||
|
}
|
||||||
|
orderClause := "u.created_at DESC"
|
||||||
|
if col, ok := validSorts[sort]; ok {
|
||||||
|
if order != "asc" {
|
||||||
|
order = "desc"
|
||||||
|
}
|
||||||
|
orderClause = col + " " + order
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
countSQL := "SELECT COUNT(*) FROM server_users u" + where
|
||||||
|
s.db.QueryRow(countSQL, args...).Scan(&total)
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
|
||||||
|
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, perPage, offset)
|
||||||
|
rows, err := s.db.Query(sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type userRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Confirmed int `json:"confirmed"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Devices int `json:"devices"`
|
||||||
|
}
|
||||||
|
var users []userRow
|
||||||
|
for rows.Next() {
|
||||||
|
var u userRow
|
||||||
|
var lastSeen *string
|
||||||
|
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
|
||||||
|
if lastSeen != nil {
|
||||||
|
u.LastSeen = *lastSeen
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]interface{}{
|
||||||
|
"users": users,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/admin/api/users/")
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, "/block") && r.Method == "POST" {
|
||||||
|
id := strings.TrimSuffix(path, "/block")
|
||||||
|
id = strings.TrimSuffix(id, "/")
|
||||||
|
var blocked int
|
||||||
|
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
|
||||||
|
newVal := 1
|
||||||
|
if blocked != 0 {
|
||||||
|
newVal = 0
|
||||||
|
}
|
||||||
|
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
|
||||||
|
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/reset-password") && r.Method == "POST" {
|
||||||
|
id := strings.TrimSuffix(path, "/reset-password")
|
||||||
|
id = strings.TrimSuffix(id, "/")
|
||||||
|
b := make([]byte, 12)
|
||||||
|
rand.Read(b)
|
||||||
|
newPass := hex.EncodeToString(b)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||||||
|
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "/edit") && r.Method == "POST" {
|
||||||
|
id := strings.TrimSuffix(path, "/edit")
|
||||||
|
id = strings.TrimSuffix(id, "/")
|
||||||
|
var editReq struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&editReq); err != nil {
|
||||||
|
jsonErr(w, 400, "bad json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if editReq.Username == "" || editReq.Email == "" {
|
||||||
|
jsonErr(w, 400, "username and email required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", editReq.Username, strings.ToLower(editReq.Email), id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]interface{}{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == "DELETE" {
|
||||||
|
id := strings.TrimSuffix(path, "/")
|
||||||
|
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
|
||||||
|
var deviceIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var did string
|
||||||
|
rows.Scan(&did)
|
||||||
|
deviceIDs = append(deviceIDs, did)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, did := range deviceIDs {
|
||||||
|
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
|
||||||
|
}
|
||||||
|
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
|
||||||
|
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
|
||||||
|
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
|
||||||
|
jsonOK(w, map[string]interface{}{"status": "deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
w.Write([]byte(adminCreateUserHTML(locale)))
|
||||||
|
case "POST":
|
||||||
|
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 {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), err.Error(), "/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 !s.requireAdminCookie(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != "POST" {
|
||||||
|
jsonErr(w, 405, "POST required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonErr(w, 400, "bad json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||||
|
jsonErr(w, 400, "username, email and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validatePassword(req.Password); err != "" {
|
||||||
|
jsonErr(w, 400, string(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, "internal error")
|
||||||
|
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, req.Username, strings.ToLower(req.Email), string(hash), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
jsonErr(w, 409, "username or email already taken")
|
||||||
|
} else {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"status": "ok", "user_id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
var _ = time.Now
|
var _ = time.Now
|
||||||
var _ = rand.Read
|
var _ = rand.Read
|
||||||
var _ = hex.EncodeToString
|
var _ = hex.EncodeToString
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
func t(locale, key string) string {
|
||||||
|
if translations, ok := _translations[locale]; ok {
|
||||||
|
if v, ok := translations[key]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if translations, ok := _translations["ru"]; ok {
|
||||||
|
if v, ok := translations[key]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
var _translations = map[string]map[string]string{
|
||||||
|
"ru": {
|
||||||
|
"server.registerTitle": "Регистрация",
|
||||||
|
"server.register": "Регистрация",
|
||||||
|
"server.username": "Имя пользователя",
|
||||||
|
"server.email": "Email",
|
||||||
|
"server.password": "Пароль",
|
||||||
|
"server.registerBtn": "Зарегистрироваться",
|
||||||
|
"server.alreadyHaveAccount": "Уже есть аккаунт?",
|
||||||
|
"server.loginBtn": "Войти",
|
||||||
|
"server.loginTitle": "Вход",
|
||||||
|
"server.usernameOrEmail": "Имя пользователя или email",
|
||||||
|
"server.forgotPassword": "Забыли пароль?",
|
||||||
|
"server.adminLink": "Админ",
|
||||||
|
"server.dashboard": "Панель управления",
|
||||||
|
"server.allFieldsRequired": "Все поля обязательны",
|
||||||
|
"server.back": "Назад",
|
||||||
|
"server.emailConfirmBody": "Подтвердите регистрацию: %s",
|
||||||
|
"server.emailConfirmSubject": "Verstak — подтверждение email",
|
||||||
|
"server.registrationSuccess": "Регистрация успешна",
|
||||||
|
"server.registrationEmailSent": "Письмо с ссылкой подтверждения отправлено.",
|
||||||
|
"server.registrationCheckEmail":"Проверьте почту и подтвердите аккаунт.",
|
||||||
|
"server.registrationAutoMessage":"Email не подтверждается. Токен подтверждения выведен в лог сервера.",
|
||||||
|
"server.resetPasswordTitle": "Сброс пароля",
|
||||||
|
"server.resetPassword": "Сброс пароля",
|
||||||
|
"server.resetInstruction": "Введите email для получения ссылки сброса.",
|
||||||
|
"server.sendLink": "Отправить ссылку",
|
||||||
|
"server.backToLogin": "Вернуться к входу",
|
||||||
|
"server.emailSentTitle": "Письмо отправлено",
|
||||||
|
"server.emailSent": "Письмо отправлено",
|
||||||
|
"server.emailSentMessage": "Если аккаунт существует, ссылка для сброса пароля отправлена на почту.",
|
||||||
|
"server.goHome": "На главную",
|
||||||
|
"server.newPasswordTitle": "Новый пароль",
|
||||||
|
"server.newPassword": "Новый пароль",
|
||||||
|
"server.passwordConfirm": "Подтвердите пароль",
|
||||||
|
"server.save": "Сохранить",
|
||||||
|
"server.passwordChanged": "Пароль изменён",
|
||||||
|
"server.passwordChangedMessage":"Пароль успешно изменён. Теперь можно войти.",
|
||||||
|
"server.emailConfirmed": "Email подтверждён",
|
||||||
|
"server.emailConfirmedMessage": "Аккаунт активирован. Теперь можно войти.",
|
||||||
|
"server.needEmail": "Введите email",
|
||||||
|
"server.passwordsDoNotMatch": "Пароли не совпадают",
|
||||||
|
"server.newPasswordResult": "Новый пароль для %s:\n",
|
||||||
|
"server.logout": "Выйти",
|
||||||
|
"server.error": "Ошибка",
|
||||||
|
"userDashboard.devices": "Устройства",
|
||||||
|
"userDashboard.device": "Устройство",
|
||||||
|
"userDashboard.status": "Статус",
|
||||||
|
"userDashboard.connected": "Подключено",
|
||||||
|
"userDashboard.lastSeen": "Последний раз",
|
||||||
|
"userDashboard.version": "Версия",
|
||||||
|
"userDashboard.connectNew": "Подключить новое устройство",
|
||||||
|
"userDashboard.connectNewHint": "Установите Верстак на новом устройстве и выполните регистрацию.",
|
||||||
|
"userDashboard.revokeConfirm": "Отозвать устройство?",
|
||||||
|
"userDashboard.revokePrompt": "Введите пароль для подтверждения:",
|
||||||
|
"userDashboard.noDevices": "Нет устройств",
|
||||||
|
"userDashboard.active": "Активно",
|
||||||
|
"userDashboard.revoked": "Отозвано",
|
||||||
|
"userDashboard.revoke": "Отозвать",
|
||||||
|
"admin.login": "Вход администратора",
|
||||||
|
"admin.username": "Имя пользователя",
|
||||||
|
"admin.password": "Пароль",
|
||||||
|
"admin.loginBtn": "Войти",
|
||||||
|
"admin.dashboard": "Панель управления",
|
||||||
|
"admin.deviceCount": "Устройств",
|
||||||
|
"admin.opsCount": "Операций",
|
||||||
|
"admin.devices": "Устройства",
|
||||||
|
"admin.noDevices": "Нет устройств",
|
||||||
|
"admin.device": "Устройство",
|
||||||
|
"admin.user": "Пользователь",
|
||||||
|
"admin.version": "Версия",
|
||||||
|
"admin.status": "Статус",
|
||||||
|
"admin.lastSeen": "Последний раз",
|
||||||
|
"admin.active": "Активно",
|
||||||
|
"admin.revoked": "Отозвано",
|
||||||
|
"admin.revoke": "Отозвать",
|
||||||
|
"admin.smtp": "SMTP",
|
||||||
|
"admin.users": "Пользователи",
|
||||||
|
"admin.usersHeading": "Пользователи",
|
||||||
|
"admin.healthCheck": "Проверка здоровья",
|
||||||
|
"admin.smtpServer": "SMTP сервер",
|
||||||
|
"admin.smtpPort": "SMTP порт",
|
||||||
|
"admin.smtpType": "Тип шифрования",
|
||||||
|
"admin.smtpNoEncryption": "Без шифрования",
|
||||||
|
"admin.smtpUsername": "Имя пользователя SMTP",
|
||||||
|
"admin.smtpPassword": "Пароль SMTP",
|
||||||
|
"admin.smtpFrom": "Отправитель",
|
||||||
|
"admin.smtpServerURL": "URL сервера",
|
||||||
|
"admin.smtpSave": "Сохранить",
|
||||||
|
"admin.smtpTest": "Тест",
|
||||||
|
"admin.smtpTitle": "Настройки SMTP",
|
||||||
|
"admin.smtpTesting": "Проверка...",
|
||||||
|
"admin.smtpPassed": "✓ Тест пройден",
|
||||||
|
"admin.revokeConfirm": "Вы уверены?",
|
||||||
|
"common.loading": "Загрузка...",
|
||||||
|
"common.ok": "OK",
|
||||||
|
"common.error": "Ошибка",
|
||||||
|
"admin.filterPlaceholder": "Поиск...",
|
||||||
|
"admin.email": "Email",
|
||||||
|
"admin.actions": "Действия",
|
||||||
|
"admin.confirmTitle": "Подтверждение",
|
||||||
|
"admin.modalCancel": "Отмена",
|
||||||
|
"admin.modalConfirm": "Подтвердить",
|
||||||
|
"admin.editUser": "Редактировать пользователя",
|
||||||
|
"admin.editBtn": "Сохранить",
|
||||||
|
"admin.resultTitle": "Результат",
|
||||||
|
"admin.confirmed": "Подтверждён",
|
||||||
|
"admin.unconfirmed": "Не подтверждён",
|
||||||
|
"admin.blocked": "Заблокирован",
|
||||||
|
"admin.unblock": "Разблокировать",
|
||||||
|
"admin.block": "Заблокировать",
|
||||||
|
"admin.resetPassword": "Сбросить пароль",
|
||||||
|
"admin.noUsers": "Нет пользователей",
|
||||||
|
"admin.resetPasswordConfirm": "Сбросить пароль",
|
||||||
|
"admin.resetPasswordMessage": "Новый пароль: ",
|
||||||
|
"admin.resetBtn": "Сбросить",
|
||||||
|
"admin.deleteUser": "Удалить",
|
||||||
|
"admin.deleteUserMessage": "Удалить пользователя %s?",
|
||||||
|
"admin.deleteBtn": "Удалить",
|
||||||
|
"admin.unblockUserTitle": "Разблокировать",
|
||||||
|
"admin.blockUserTitle": "Заблокировать",
|
||||||
|
"admin.unblockUserMessage": "Разблокировать пользователя?",
|
||||||
|
"admin.blockUserMessage": "Заблокировать пользователя?",
|
||||||
|
"admin.createUser": "Создать пользователя",
|
||||||
|
"admin.createUserBtn": "Создать",
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"server.registerTitle": "Registration",
|
||||||
|
"server.register": "Register",
|
||||||
|
"server.username": "Username",
|
||||||
|
"server.email": "Email",
|
||||||
|
"server.password": "Password",
|
||||||
|
"server.registerBtn": "Register",
|
||||||
|
"server.alreadyHaveAccount": "Already have an account?",
|
||||||
|
"server.loginBtn": "Login",
|
||||||
|
"server.loginTitle": "Login",
|
||||||
|
"server.usernameOrEmail": "Username or email",
|
||||||
|
"server.forgotPassword": "Forgot password?",
|
||||||
|
"server.adminLink": "Admin",
|
||||||
|
"server.dashboard": "Dashboard",
|
||||||
|
"server.allFieldsRequired": "All fields are required",
|
||||||
|
"server.back": "Back",
|
||||||
|
"server.emailConfirmBody": "Confirm registration: %s",
|
||||||
|
"server.emailConfirmSubject": "Verstak — email confirmation",
|
||||||
|
"server.registrationSuccess": "Registration successful",
|
||||||
|
"server.registrationEmailSent": "Confirmation link has been sent.",
|
||||||
|
"server.registrationCheckEmail":"Check your email and confirm your account.",
|
||||||
|
"server.registrationAutoMessage":"Email is not confirmed. Confirmation token logged to server.",
|
||||||
|
"server.resetPasswordTitle": "Reset Password",
|
||||||
|
"server.resetPassword": "Reset Password",
|
||||||
|
"server.resetInstruction": "Enter your email to receive a reset link.",
|
||||||
|
"server.sendLink": "Send link",
|
||||||
|
"server.backToLogin": "Back to login",
|
||||||
|
"server.emailSentTitle": "Email sent",
|
||||||
|
"server.emailSent": "Email sent",
|
||||||
|
"server.emailSentMessage": "If the account exists, a reset link has been sent.",
|
||||||
|
"server.goHome": "Go home",
|
||||||
|
"server.newPasswordTitle": "New Password",
|
||||||
|
"server.newPassword": "New Password",
|
||||||
|
"server.passwordConfirm": "Confirm Password",
|
||||||
|
"server.save": "Save",
|
||||||
|
"server.passwordChanged": "Password changed",
|
||||||
|
"server.passwordChangedMessage":"Password has been changed. You can now login.",
|
||||||
|
"server.emailConfirmed": "Email confirmed",
|
||||||
|
"server.emailConfirmedMessage": "Account activated. You can now login.",
|
||||||
|
"server.needEmail": "Enter email",
|
||||||
|
"server.passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"server.newPasswordResult": "New password for %s:\n",
|
||||||
|
"server.logout": "Logout",
|
||||||
|
"server.error": "Error",
|
||||||
|
"userDashboard.devices": "Devices",
|
||||||
|
"userDashboard.device": "Device",
|
||||||
|
"userDashboard.status": "Status",
|
||||||
|
"userDashboard.connected": "Connected",
|
||||||
|
"userDashboard.lastSeen": "Last seen",
|
||||||
|
"userDashboard.version": "Version",
|
||||||
|
"userDashboard.connectNew": "Connect new device",
|
||||||
|
"userDashboard.connectNewHint": "Install Verstak on a new device and register it.",
|
||||||
|
"userDashboard.revokeConfirm": "Revoke device?",
|
||||||
|
"userDashboard.revokePrompt": "Enter password to confirm:",
|
||||||
|
"userDashboard.noDevices": "No devices",
|
||||||
|
"userDashboard.active": "Active",
|
||||||
|
"userDashboard.revoked": "Revoked",
|
||||||
|
"userDashboard.revoke": "Revoke",
|
||||||
|
"admin.login": "Admin Login",
|
||||||
|
"admin.username": "Username",
|
||||||
|
"admin.password": "Password",
|
||||||
|
"admin.loginBtn": "Login",
|
||||||
|
"admin.dashboard": "Admin Dashboard",
|
||||||
|
"admin.deviceCount": "Devices",
|
||||||
|
"admin.opsCount": "Operations",
|
||||||
|
"admin.devices": "Devices",
|
||||||
|
"admin.noDevices": "No devices",
|
||||||
|
"admin.device": "Device",
|
||||||
|
"admin.user": "User",
|
||||||
|
"admin.version": "Version",
|
||||||
|
"admin.status": "Status",
|
||||||
|
"admin.lastSeen": "Last seen",
|
||||||
|
"admin.active": "Active",
|
||||||
|
"admin.revoked": "Revoked",
|
||||||
|
"admin.revoke": "Revoke",
|
||||||
|
"admin.smtp": "SMTP",
|
||||||
|
"admin.users": "Users",
|
||||||
|
"admin.usersHeading": "Users",
|
||||||
|
"admin.healthCheck": "Health Check",
|
||||||
|
"admin.smtpServer": "SMTP Server",
|
||||||
|
"admin.smtpPort": "SMTP Port",
|
||||||
|
"admin.smtpType": "Encryption",
|
||||||
|
"admin.smtpNoEncryption": "None",
|
||||||
|
"admin.smtpUsername": "SMTP Username",
|
||||||
|
"admin.smtpPassword": "SMTP Password",
|
||||||
|
"admin.smtpFrom": "From",
|
||||||
|
"admin.smtpServerURL": "Server URL",
|
||||||
|
"admin.smtpSave": "Save",
|
||||||
|
"admin.smtpTest": "Test",
|
||||||
|
"admin.smtpTitle": "SMTP Settings",
|
||||||
|
"admin.smtpTesting": "Testing...",
|
||||||
|
"admin.smtpPassed": "✓ Test passed",
|
||||||
|
"admin.revokeConfirm": "Are you sure?",
|
||||||
|
"common.loading": "Loading...",
|
||||||
|
"common.ok": "OK",
|
||||||
|
"common.error": "Error",
|
||||||
|
"admin.filterPlaceholder": "Search...",
|
||||||
|
"admin.email": "Email",
|
||||||
|
"admin.actions": "Actions",
|
||||||
|
"admin.confirmTitle": "Confirm",
|
||||||
|
"admin.modalCancel": "Cancel",
|
||||||
|
"admin.modalConfirm": "Confirm",
|
||||||
|
"admin.editUser": "Edit User",
|
||||||
|
"admin.editBtn": "Save",
|
||||||
|
"admin.resultTitle": "Result",
|
||||||
|
"admin.confirmed": "Confirmed",
|
||||||
|
"admin.unconfirmed": "Unconfirmed",
|
||||||
|
"admin.blocked": "Blocked",
|
||||||
|
"admin.unblock": "Unblock",
|
||||||
|
"admin.block": "Block",
|
||||||
|
"admin.resetPassword": "Reset Password",
|
||||||
|
"admin.noUsers": "No users",
|
||||||
|
"admin.resetPasswordConfirm": "Reset Password",
|
||||||
|
"admin.resetPasswordMessage": "New password: ",
|
||||||
|
"admin.resetBtn": "Reset",
|
||||||
|
"admin.deleteUser": "Delete",
|
||||||
|
"admin.deleteUserMessage": "Delete user %s?",
|
||||||
|
"admin.deleteBtn": "Delete",
|
||||||
|
"admin.unblockUserTitle": "Unblock",
|
||||||
|
"admin.blockUserTitle": "Block",
|
||||||
|
"admin.unblockUserMessage": "Unblock user?",
|
||||||
|
"admin.blockUserMessage": "Block user?",
|
||||||
|
"admin.createUser": "Create User",
|
||||||
|
"admin.createUserBtn": "Create",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,26 @@ func (s *Server) routes() {
|
||||||
s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
|
s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
|
||||||
s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
|
s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
|
||||||
s.mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
|
s.mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
|
||||||
|
s.mux.HandleFunc("/register", s.handleUserWebRegister)
|
||||||
|
s.mux.HandleFunc("/login", s.handleUserWebLogin)
|
||||||
|
s.mux.HandleFunc("/dashboard", s.handleUserDashboard)
|
||||||
|
s.mux.HandleFunc("/forgot", s.handleUserWebForgot)
|
||||||
|
s.mux.HandleFunc("/reset", s.handleUserWebReset)
|
||||||
|
s.mux.HandleFunc("/logout", s.handleUserWebLogout)
|
||||||
|
s.mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
|
||||||
s.mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
s.mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
||||||
s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
|
s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
|
||||||
s.mux.HandleFunc("/admin/users", s.handleAdminUsers)
|
s.mux.HandleFunc("/admin/users", s.handleAdminUsers)
|
||||||
|
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUser)
|
||||||
|
s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser)
|
||||||
s.mux.HandleFunc("/admin/devices", s.handleAdminDevices)
|
s.mux.HandleFunc("/admin/devices", s.handleAdminDevices)
|
||||||
|
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)
|
||||||
|
s.mux.HandleFunc("/admin/api/devices", s.handleAdminAPIDevices)
|
||||||
|
s.mux.HandleFunc("/admin/api/keys/", s.handleAdminAPIKeysDelete)
|
||||||
|
s.mux.HandleFunc("/admin/api/keys", s.handleAdminAPIKeys)
|
||||||
|
s.mux.HandleFunc("/admin/api/users/", s.handleAdminAPIUserActions)
|
||||||
|
s.mux.HandleFunc("/admin/api/users", s.handleAdminAPIUsers)
|
||||||
s.mux.HandleFunc("/", s.handleNotFound)
|
s.mux.HandleFunc("/", s.handleNotFound)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,816 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"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}
|
||||||
|
.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" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
|
||||||
|
<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 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 · <a href="/logout">%[2]s</a></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/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminCreateUserHTML(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>%[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">
|
||||||
|
<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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorPageHTML(locale, title, msg, backURL 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;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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue