diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go index 43fb3b4..bd364fb 100644 --- a/internal/server/handlers_admin.go +++ b/internal/server/handlers_admin.go @@ -4,9 +4,12 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "fmt" "net/http" "strings" "time" + + "golang.org/x/crypto/bcrypt" ) func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { @@ -169,6 +172,427 @@ func intToStr(n int) string { 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 _ = rand.Read var _ = hex.EncodeToString diff --git a/internal/server/locale.go b/internal/server/locale.go new file mode 100644 index 0000000..4dd4b12 --- /dev/null +++ b/internal/server/locale.go @@ -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", + }, +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 33063d8..608da62 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -16,9 +16,26 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin) s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot) 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/dashboard", s.handleAdminDashboard) 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/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) } diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..47c52f5 --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,816 @@ +package server + +import ( + "fmt" + "strings" +) + +func userRegisterHTML(locale string) string { + return fmt.Sprintf(` + + +Verstak Sync — %s + + +
+

%s

+ + + + + + + +

%s %s

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

Verstak Sync

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

%s

+

%s

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

%s

+

%s

+

%s

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

%s

+

%s

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

%s

+

%s

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

%s

+

%s

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

%s

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

%s

+

%s

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

Verstak Sync Server

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

%[4]s

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

Verstak Sync

+%[1]s · %[2]s +
+

%[3]s

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

%[10]s

+

%[11]s

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

%[2]s

+ + + + + + + +

%[7]s

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

%s

+

%s

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

%[2]s

+

%[3]s

+ +
+ + +
+ + + + + + + + + + + +
%[5]s %[6]s %[7]s %[8]s %[9]s %[10]s
+ + + + + + + + + + +`, + t(locale, "admin.users"), + t(locale, "admin.usersHeading"), + t(locale, "server.dashboard"), + t(locale, "admin.filterPlaceholder"), + t(locale, "admin.username"), + t(locale, "admin.email"), + t(locale, "admin.status"), + t(locale, "admin.devices"), + t(locale, "admin.lastSeen"), + t(locale, "admin.actions"), + t(locale, "admin.confirmTitle"), + t(locale, "admin.modalCancel"), + t(locale, "admin.modalConfirm"), + t(locale, "admin.editUser"), + t(locale, "admin.username"), + t(locale, "admin.email"), + t(locale, "admin.modalCancel"), + t(locale, "admin.editBtn"), + t(locale, "admin.resultTitle"), + t(locale, "common.ok"), + t(locale, "admin.confirmed"), + t(locale, "admin.unconfirmed"), + t(locale, "admin.blocked"), + t(locale, "admin.unblock"), + t(locale, "admin.block"), + t(locale, "admin.resetPassword"), + t(locale, "admin.noUsers"), + t(locale, "server.newPassword"), + newPassPrefix, + newPassSuffix, + t(locale, "admin.resetPasswordConfirm"), + t(locale, "admin.resetPasswordMessage"), + t(locale, "admin.resetBtn"), + t(locale, "admin.deleteUser"), + delMsgPrefix, + delMsgSuffix, + t(locale, "admin.deleteBtn"), + t(locale, "admin.unblockUserTitle"), + t(locale, "admin.blockUserTitle"), + t(locale, "admin.unblockUserMessage"), + t(locale, "admin.blockUserMessage"), + t(locale, "admin.createUser"), + ) +}