feat: styled registration/confirm pages with login link, consistent theme
This commit is contained in:
parent
daed8e0aba
commit
e5860ca076
|
|
@ -12,6 +12,7 @@ import (
|
|||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -233,6 +234,8 @@ CREATE TABLE IF NOT EXISTS server_users (
|
|||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
|
@ -282,6 +285,9 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
|
|||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
}
|
||||
// Migrations for older databases.
|
||||
db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
|
||||
db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT")
|
||||
|
||||
blobsDir := filepath.Join(dataDir, "blobs")
|
||||
if err := os.MkdirAll(blobsDir, 0750); err != nil {
|
||||
|
|
@ -331,6 +337,7 @@ func (s *Server) routes() *http.ServeMux {
|
|||
mux.HandleFunc("/logout", s.handleUserWebLogout)
|
||||
mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
||||
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
|
||||
mux.HandleFunc("/admin/users", s.handleAdminUsers)
|
||||
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
|
||||
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
|
||||
mux.HandleFunc("/admin/", s.handleAdminAPI)
|
||||
|
|
@ -602,13 +609,17 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Look up user by username or email.
|
||||
var userID, hash string
|
||||
var confirmed int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed)
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
|
|
@ -745,7 +756,7 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
|||
log.Printf("confirm: user %s confirmed email", userID)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte("<html><body><h1>Email confirmed</h1><p>You can now log in.</p></body></html>"))
|
||||
w.Write([]byte(confirmedHTML))
|
||||
}
|
||||
|
||||
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -766,13 +777,17 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
var userID, hash string
|
||||
var confirmed int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed)
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
|
|
@ -781,6 +796,7 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
|||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
|
||||
tok := s.userTokens.Create(userID)
|
||||
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
|
||||
}
|
||||
|
|
@ -1195,7 +1211,11 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
|||
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte("<html><body><h1>Registration successful</h1><p>Check your email to confirm (or if SMTP not configured, check server logs for the token).</p><a href='/login'>Log in</a></body></html>"))
|
||||
regMsg := registrationOKHTML
|
||||
if host == "" {
|
||||
regMsg = registrationAutoHTML
|
||||
}
|
||||
w.Write([]byte(regMsg))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
|
|
@ -1214,10 +1234,10 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
|||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
var userID, hash string
|
||||
var confirmed int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?",
|
||||
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed)
|
||||
if err != nil || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte("<html><body><h1>401 Unauthorized</h1><a href='/login'>Try again</a></body></html>"))
|
||||
|
|
@ -1453,6 +1473,7 @@ pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;o
|
|||
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openSMTP()">Настройка SMTP</button>
|
||||
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">Пользователи</button></a>
|
||||
<button class="btn" onclick="openHealth()">Health check</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -1538,6 +1559,14 @@ function testSMTP(){
|
|||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(adminUsersHTML))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
|
|
@ -1654,6 +1683,158 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
|
||||
case path == "/api/users" && r.Method == "GET":
|
||||
filter := r.URL.Query().Get("filter")
|
||||
sort := r.URL.Query().Get("sort")
|
||||
order := r.URL.Query().Get("order")
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||
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
|
||||
}
|
||||
// Count total.
|
||||
var total int
|
||||
countSQL := "SELECT COUNT(*) FROM server_users u" + where
|
||||
s.db.QueryRow(countSQL, args...).Scan(&total)
|
||||
// Fetch page.
|
||||
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,
|
||||
})
|
||||
|
||||
case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
|
||||
sub := strings.TrimPrefix(path, "/api/users/")
|
||||
if strings.HasSuffix(sub, "/block") {
|
||||
id := strings.TrimSuffix(sub, "/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(sub, "/reset-password") {
|
||||
id := strings.TrimSuffix(sub, "/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(sub, "/edit") {
|
||||
id := strings.TrimSuffix(sub, "/edit")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Email == "" {
|
||||
jsonErr(w, 400, "username and email required")
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"status": "ok"})
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "unknown action")
|
||||
|
||||
case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
|
||||
id := strings.TrimPrefix(path, "/api/users/")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
// Get user devices to delete.
|
||||
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"})
|
||||
|
||||
default:
|
||||
jsonErr(w, 404, "not found")
|
||||
}
|
||||
|
|
@ -1739,3 +1920,243 @@ button:hover{background:#4f46e5}</style>
|
|||
<button>Войти</button>
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
const adminUsersHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Пользователи</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>Пользователи</h1>
|
||||
<p><a href="/admin/dashboard">← Дашборд</a></p>
|
||||
|
||||
<div class="toolbar">
|
||||
<input id="filter-input" placeholder="Фильтр по логину..." style="width:200px" onkeyup="loadUsers()">
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th onclick="sortBy('username')">Логин <span id="s-username"></span></th>
|
||||
<th onclick="sortBy('email')">Email <span id="s-email"></span></th>
|
||||
<th onclick="sortBy('confirmed')">Статус <span id="s-confirmed"></span></th>
|
||||
<th onclick="sortBy('devices')">Устройств <span id="s-devices"></span></th>
|
||||
<th onclick="sortBy('last_seen')">Активность <span id="s-last_seen"></span></th>
|
||||
<th>Действия</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">Подтверждение</h2>
|
||||
<p id="confirm-text"></p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
||||
<button class="btn" onclick="closeConfirm()">Отмена</button>
|
||||
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">Да</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>Редактировать пользователя</h2>
|
||||
<div class="form-row"><label>Логин</label><input id="edit-username"></div>
|
||||
<div class="form-row"><label>Email</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()">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="saveEdit()">Сохранить</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">Результат</h2>
|
||||
<p id="result-text" style="white-space:pre-wrap"></p>
|
||||
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">OK</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">Подтверждён</span>':'<span class="badge badge-yellow">Не подтверждён</span>'
|
||||
if(u.blocked){status='<span class="badge badge-red">Заблокирован</span>'}
|
||||
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
|
||||
var blockText=u.blocked?'Разблокировать':'Заблокировать'
|
||||
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+'\')">Сброс пароля</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">Нет пользователей</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?'Разблокировать пользователя?':'Заблокировать пользователя?'
|
||||
document.getElementById('confirm-text').textContent=blocked?'Пользователь сможет снова войти.':'Пользователь не сможет войти.'
|
||||
document.getElementById('confirm-btn').textContent=blocked?'Разблокировать':'Заблокировать'
|
||||
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='Новый пароль'
|
||||
document.getElementById('result-text').textContent='Новый пароль: '+d.new_password+'\nСообщите его пользователю.'
|
||||
document.getElementById('result-modal').style.display='flex'})}
|
||||
document.getElementById('confirm-title').textContent='Сбросить пароль?'
|
||||
document.getElementById('confirm-text').textContent='Пользователь не сможет войти со старым паролем.'
|
||||
document.getElementById('confirm-btn').textContent='Сбросить'
|
||||
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='Удалить пользователя?'
|
||||
document.getElementById('confirm-text').textContent='Будет удалён пользователь "'+username+'" и все его устройства.'
|
||||
document.getElementById('confirm-btn').textContent='Удалить'
|
||||
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>`
|
||||
|
||||
const confirmedHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Email подтверждён</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>✓ Email подтверждён</h1>
|
||||
<p>Ваш email успешно подтверждён. Теперь вы можете войти в систему.</p>
|
||||
<a href="/login" class="btn">Войти</a>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
const registrationOKHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Регистрация</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>✓ Регистрация успешна</h1>
|
||||
<p>На вашу почту отправлено письмо с подтверждением.</p>
|
||||
<p>Перейдите по ссылке в письме, чтобы активировать аккаунт.</p>
|
||||
<a href="/login" class="btn">Войти</a>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
const registrationAutoHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Регистрация</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>✓ Регистрация успешна</h1>
|
||||
<p>Вы можете войти — подтверждение email не требуется.</p>
|
||||
<a href="/login" class="btn">Войти</a>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
|
|
|||
Loading…
Reference in New Issue