From e5651a2727495952156abd5b4ec429e20d32f8fd Mon Sep 17 00:00:00 2001 From: benya Date: Tue, 21 Jul 2026 01:41:18 +0300 Subject: [PATCH] feat: add landing page and account settings --- docs/api.md | 8 + server/README.md | 10 +- server/internal/httpapi/auth.go | 89 ++++++++ server/internal/httpapi/server.go | 16 ++ server/internal/httpapi/server_test.go | 64 ++++++ server/internal/model/model.go | 14 +- server/internal/store/security.go | 58 ++++- server/internal/store/sqlite.go | 4 + web/app.js | 116 +++++++++- web/index.html | 121 ++++++++--- web/styles.css | 280 +++++++++++++++++++++++++ 11 files changed, 725 insertions(+), 55 deletions(-) diff --git a/docs/api.md b/docs/api.md index 5099064..48c5c5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -636,3 +636,11 @@ Response: ] } ``` +## Account API + +Authenticated browser sessions can manage their own account without administrator access: + +- `GET /api/auth/me` returns the current user, including `display_name` and `email`. +- `PATCH /api/auth/profile` accepts `display_name` and `email`. +- `POST /api/auth/change-password` accepts `current_password` and `new_password`; other sessions are revoked. +- `POST /api/auth/logout-all` revokes every session for the current user. diff --git a/server/README.md b/server/README.md index 554f715..09b65d9 100644 --- a/server/README.md +++ b/server/README.md @@ -12,7 +12,7 @@ Environment variables: - `RMM_ADDR` - listen address, default `:8080` - `RMM_DB_PATH` - SQLite database path, default `rmm.db` -- `RMM_OPERATOR_PASSWORD` - required bootstrap administrator password +- `RMM_OPERATOR_PASSWORD` - required password used only when the bootstrap administrator is first created; rotate it later in the account UI - `RMM_OPERATOR_TOKEN` - optional emergency/API bearer token - `RMM_OPERATOR_USERNAME` - web UI username, default `admin` - `RMM_COOKIE_SECURE` - defaults to `true` outside explicit development mode @@ -43,7 +43,11 @@ Agent API: Operator API: - `POST /api/auth/login` +- `POST /api/auth/logout` - `GET /api/auth/me` +- `PATCH /api/auth/profile` +- `POST /api/auth/change-password` +- `POST /api/auth/logout-all` - `GET|POST /api/users` (administrator only) - `POST /api/enrollment-grants` @@ -59,6 +63,10 @@ The web UI uses revocable, server-side sessions. Each normal user only sees and devices enrolled with their own one-time grants. The optional bootstrap bearer token is administrator-scoped: +Changing a password keeps the current browser session and revokes the user's other sessions. +The bootstrap environment password does not overwrite a password stored in SQLite after a +container restart. + ```http Authorization: Bearer ``` diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index 313751c..7e12d71 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "encoding/base64" "net" + "net/mail" "net/http" "net/url" "strings" @@ -129,6 +130,94 @@ func (a *App) handleAuthMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"username": principal.User.Username, "user": principal.User}) } +func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + var req updateProfileRequest + if !decodeJSON(w, r, &req) { + return + } + req.DisplayName = strings.TrimSpace(req.DisplayName) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + if len([]rune(req.DisplayName)) > 80 { + writeError(w, http.StatusBadRequest, "display_name must not exceed 80 characters") + return + } + if len(req.Email) > 254 { + writeError(w, http.StatusBadRequest, "email must not exceed 254 characters") + return + } + if req.Email != "" { + address, err := mail.ParseAddress(req.Email) + if err != nil || !strings.EqualFold(address.Address, req.Email) { + writeError(w, http.StatusBadRequest, "email is invalid") + return + } + } + principal, _ := principalFromContext(r.Context()) + user, found, err := a.store.UpdateUserProfile(r.Context(), principal.User.ID, req.DisplayName, req.Email) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update profile") + return + } + if !found { + writeError(w, http.StatusNotFound, "user not found") + return + } + _, _ = a.store.AddAuditEvent(r.Context(), user.Username, "auth.profile_update", "", "", mustJSON(map[string]string{ + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) { + var req changePasswordRequest + if !decodeJSON(w, r, &req) { + return + } + principal, _ := principalFromContext(r.Context()) + _, currentHash, found, err := a.store.GetUserByUsername(r.Context(), principal.User.Username) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify password") + return + } + if !found || !authn.VerifyPassword(currentHash, req.CurrentPassword) { + writeError(w, http.StatusUnauthorized, "current password is incorrect") + return + } + if req.CurrentPassword == req.NewPassword { + writeError(w, http.StatusBadRequest, "new password must be different") + return + } + passwordHash, err := authn.HashPassword(req.NewPassword) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := a.store.UpdateOwnPassword(r.Context(), principal.User.ID, passwordHash, principal.SessionHash); err != nil { + writeError(w, http.StatusInternalServerError, "failed to change password") + return + } + _, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "auth.password_change", "", "", mustJSON(map[string]string{ + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (a *App) handleLogoutAll(w http.ResponseWriter, r *http.Request) { + principal, _ := principalFromContext(r.Context()) + if err := a.store.RevokeUserSessions(r.Context(), principal.User.ID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to revoke sessions") + return + } + http.SetCookie(w, &http.Cookie{ + Name: operatorSessionCookie, Value: "", Path: "/", HttpOnly: true, + Secure: a.cookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(0, 0), + }) + _, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "auth.logout_all", "", "", mustJSON(map[string]string{ + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + func (a *App) authenticateOperator(r *http.Request) (authPrincipal, bool) { if token, ok := bearerToken(r); ok && a.operatorToken != "" && constantTimeEqual(token, a.operatorToken) { user, _, found, err := a.store.GetUserByUsername(r.Context(), a.operatorUsername) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 847c1fb..bbc3231 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -37,6 +37,9 @@ type Store interface { CreateOperatorSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error AuthorizeOperatorSession(ctx context.Context, tokenHash string) (model.User, bool, error) RevokeOperatorSession(ctx context.Context, tokenHash string) error + UpdateUserProfile(ctx context.Context, userID, displayName, email string) (model.User, bool, error) + UpdateOwnPassword(ctx context.Context, userID, passwordHash, currentSessionHash string) error + RevokeUserSessions(ctx context.Context, userID string) error CreateEnrollmentGrant(ctx context.Context, userID, dnsLabel, tokenHash string, expiresAt time.Time) (model.EnrollmentGrant, error) ListDevicesForUser(ctx context.Context, userID string, admin bool) ([]model.Device, error) DeviceAccessible(ctx context.Context, deviceID, userID string, admin bool) (bool, error) @@ -185,6 +188,16 @@ type updateUserRequest struct { Password string `json:"password"` } +type updateProfileRequest struct { + DisplayName string `json:"display_name"` + Email string `json:"email"` +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + type enrollmentGrantRequest struct { DNSLabel string `json:"dns_label"` ExpiresSeconds int `json:"expires_seconds"` @@ -241,6 +254,9 @@ func NewHandler(s Store, cfg Config) http.Handler { mux.HandleFunc("POST /api/auth/login", a.handleLogin) mux.Handle("POST /api/auth/logout", a.operatorAuth(http.HandlerFunc(a.handleLogout))) mux.Handle("GET /api/auth/me", a.operatorAuth(http.HandlerFunc(a.handleAuthMe))) + mux.Handle("PATCH /api/auth/profile", a.operatorAuth(http.HandlerFunc(a.handleUpdateProfile))) + mux.Handle("POST /api/auth/change-password", a.operatorAuth(http.HandlerFunc(a.handleChangePassword))) + mux.Handle("POST /api/auth/logout-all", a.operatorAuth(http.HandlerFunc(a.handleLogoutAll))) mux.Handle("GET /api/users", a.operatorAuth(a.adminOnly(http.HandlerFunc(a.handleListUsers)))) mux.Handle("POST /api/users", a.operatorAuth(a.adminOnly(http.HandlerFunc(a.handleCreateUser)))) mux.Handle("PATCH /api/users/", a.operatorAuth(a.adminOnly(http.HandlerFunc(a.handleUpdateUser)))) diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index c872b78..3fc74b8 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -717,6 +717,70 @@ func TestOperatorCookieAuth(t *testing.T) { authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil) } +func TestAccountProfilePasswordAndBootstrapPersistence(t *testing.T) { + st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + config := httpapi.Config{ + OperatorUsername: "admin", + OperatorPassword: "initial-password-123", + SessionSecret: "test-session-secret-with-enough-entropy", + } + srv := httptest.NewServer(httpapi.NewHandler(st, config)) + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{ + "username": "admin", "password": "initial-password-123", + }, http.StatusOK, nil) + + var profile struct { + User model.User `json:"user"` + } + authRequestJSON(t, client, http.MethodPatch, srv.URL+"/api/auth/profile", map[string]any{ + "display_name": "Network Owner", "email": "owner@example.com", + }, http.StatusOK, &profile) + if profile.User.DisplayName != "Network Owner" || profile.User.Email != "owner@example.com" { + t.Fatalf("unexpected profile: %#v", profile.User) + } + + authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/change-password", map[string]any{ + "current_password": "wrong-password-123", "new_password": "updated-password-123", + }, http.StatusUnauthorized, nil) + authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/auth/change-password", map[string]any{ + "current_password": "initial-password-123", "new_password": "updated-password-123", + }, http.StatusOK, nil) + authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/auth/me", nil, http.StatusOK, nil) + srv.Close() + + // Recreating the handler simulates a container restart. The bootstrap + // password must not overwrite the password that the user selected. + restarted := httptest.NewServer(httpapi.NewHandler(st, config)) + defer restarted.Close() + newClient := &http.Client{} + authRequestJSON(t, newClient, http.MethodPost, restarted.URL+"/api/auth/login", map[string]any{ + "username": "admin", "password": "initial-password-123", + }, http.StatusUnauthorized, nil) + authRequestJSON(t, newClient, http.MethodPost, restarted.URL+"/api/auth/login", map[string]any{ + "username": "admin", "password": "updated-password-123", + }, http.StatusOK, nil) + + var persisted struct { + User model.User `json:"user"` + } + authRequestJSON(t, client, http.MethodGet, restarted.URL+"/api/auth/me", nil, http.StatusOK, &persisted) + if persisted.User.DisplayName != "Network Owner" || persisted.User.Email != "owner@example.com" { + t.Fatalf("profile was not persisted: %#v", persisted.User) + } + authRequestJSON(t, client, http.MethodPost, restarted.URL+"/api/auth/logout-all", nil, http.StatusOK, nil) + authRequestJSON(t, client, http.MethodGet, restarted.URL+"/api/auth/me", nil, http.StatusUnauthorized, nil) +} + func authRequestJSON(t *testing.T, client *http.Client, method, url string, body any, wantStatus int, out any) { t.Helper() var reader io.Reader diff --git a/server/internal/model/model.go b/server/internal/model/model.go index a036e38..6932e3b 100644 --- a/server/internal/model/model.go +++ b/server/internal/model/model.go @@ -23,12 +23,14 @@ type Device struct { } type User struct { - ID string `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - Disabled bool `json:"disabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role string `json:"role"` + Disabled bool `json:"disabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type EnrollmentGrant struct { diff --git a/server/internal/store/security.go b/server/internal/store/security.go index 38a2b40..8a20a8a 100644 --- a/server/internal/store/security.go +++ b/server/internal/store/security.go @@ -27,10 +27,9 @@ func (s *Store) EnsureBootstrapUser(ctx context.Context, username, passwordHash if user, _, found, err := s.GetUserByUsername(ctx, username); err != nil { return model.User{}, err } else if found { - if _, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, role = 'admin', disabled = 0, updated_at = ? WHERE id = ?`, passwordHash, nowText(), user.ID); err != nil { - return model.User{}, err - } - if _, err := s.db.ExecContext(ctx, `DELETE FROM operator_sessions WHERE user_id = ?`, user.ID); err != nil { + // The environment password is a first-run bootstrap secret. Preserve a + // password changed from the account UI on subsequent server restarts. + if _, err := s.db.ExecContext(ctx, `UPDATE users SET role = 'admin', disabled = 0, updated_at = ? WHERE id = ?`, nowText(), user.ID); err != nil { return model.User{}, err } if err := s.assignLegacyDevices(ctx, user.ID); err != nil { @@ -131,7 +130,7 @@ func (s *Store) UpdateUserSecurity(ctx context.Context, userID string, disabled func (s *Store) ListUsers(ctx context.Context) ([]model.User, error) { rows, err := s.db.QueryContext(ctx, ` -SELECT id, username, role, disabled, created_at, updated_at +SELECT id, username, display_name, email, role, disabled, created_at, updated_at FROM users ORDER BY username COLLATE NOCASE `) @@ -152,13 +151,13 @@ ORDER BY username COLLATE NOCASE func (s *Store) GetUserByUsername(ctx context.Context, username string) (model.User, string, bool, error) { row := s.db.QueryRowContext(ctx, ` -SELECT id, username, role, disabled, created_at, updated_at, password_hash +SELECT id, username, display_name, email, role, disabled, created_at, updated_at, password_hash FROM users WHERE username = ? COLLATE NOCASE `, strings.TrimSpace(username)) var user model.User var disabled int var createdAt, updatedAt, passwordHash string - if err := row.Scan(&user.ID, &user.Username, &user.Role, &disabled, &createdAt, &updatedAt, &passwordHash); errors.Is(err, sql.ErrNoRows) { + if err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Role, &disabled, &createdAt, &updatedAt, &passwordHash); errors.Is(err, sql.ErrNoRows) { return model.User{}, "", false, nil } else if err != nil { return model.User{}, "", false, err @@ -171,7 +170,7 @@ FROM users WHERE username = ? COLLATE NOCASE func (s *Store) GetUserByID(ctx context.Context, id string) (model.User, bool, error) { row := s.db.QueryRowContext(ctx, ` -SELECT id, username, role, disabled, created_at, updated_at FROM users WHERE id = ? +SELECT id, username, display_name, email, role, disabled, created_at, updated_at FROM users WHERE id = ? `, id) user, err := scanUser(row) if errors.Is(err, sql.ErrNoRows) { @@ -190,7 +189,7 @@ VALUES (?, ?, ?, ?) func (s *Store) AuthorizeOperatorSession(ctx context.Context, tokenHash string) (model.User, bool, error) { row := s.db.QueryRowContext(ctx, ` -SELECT u.id, u.username, u.role, u.disabled, u.created_at, u.updated_at +SELECT u.id, u.username, u.display_name, u.email, u.role, u.disabled, u.created_at, u.updated_at FROM operator_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND julianday(s.expires_at) > julianday(?) AND u.disabled = 0 @@ -207,6 +206,45 @@ func (s *Store) RevokeOperatorSession(ctx context.Context, tokenHash string) err return err } +func (s *Store) UpdateUserProfile(ctx context.Context, userID, displayName, email string) (model.User, bool, error) { + result, err := s.db.ExecContext(ctx, ` +UPDATE users SET display_name = ?, email = ?, updated_at = ? WHERE id = ? +`, strings.TrimSpace(displayName), strings.ToLower(strings.TrimSpace(email)), nowText(), userID) + if err != nil { + return model.User{}, false, err + } + changed, err := result.RowsAffected() + if err != nil || changed == 0 { + return model.User{}, false, err + } + return s.GetUserByID(ctx, userID) +} + +func (s *Store) UpdateOwnPassword(ctx context.Context, userID, passwordHash, currentSessionHash string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, nowText(), userID); err != nil { + return err + } + if currentSessionHash == "" { + _, err = tx.ExecContext(ctx, `DELETE FROM operator_sessions WHERE user_id = ?`, userID) + } else { + _, err = tx.ExecContext(ctx, `DELETE FROM operator_sessions WHERE user_id = ? AND token_hash != ?`, userID, currentSessionHash) + } + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) RevokeUserSessions(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM operator_sessions WHERE user_id = ?`, userID) + return err +} + func (s *Store) CreateEnrollmentGrant(ctx context.Context, userID, dnsLabel, tokenHash string, expiresAt time.Time) (model.EnrollmentGrant, error) { var activeGrants int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM enrollment_grants WHERE user_id = ? AND used_at IS NULL AND julianday(expires_at) > julianday(?)`, userID, nowText()).Scan(&activeGrants); err != nil { @@ -482,7 +520,7 @@ func scanUser(row scanner) (model.User, error) { var user model.User var disabled int var createdAt, updatedAt string - err := row.Scan(&user.ID, &user.Username, &user.Role, &disabled, &createdAt, &updatedAt) + err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Role, &disabled, &createdAt, &updatedAt) user.Disabled = disabled != 0 user.CreatedAt = parseTime(createdAt) user.UpdatedAt = parseTime(updatedAt) diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index 0d664fe..37fc4c0 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -197,6 +197,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_events_device_created_at ON audit_events(de CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin', 'user')), disabled INTEGER NOT NULL DEFAULT 0, @@ -277,6 +279,8 @@ CREATE TABLE IF NOT EXISTS device_access_sessions ( `ALTER TABLE remote_sessions ADD COLUMN started_at TEXT`, `ALTER TABLE remote_sessions ADD COLUMN closed_at TEXT`, `ALTER TABLE remote_sessions ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`, } { if _, err := s.db.ExecContext(ctx, stmt); err != nil && !isDuplicateColumnError(err) { return err diff --git a/web/app.js b/web/app.js index a27ef79..7812b71 100644 --- a/web/app.js +++ b/web/app.js @@ -185,6 +185,16 @@ const els = { profileRole: document.querySelector("#profileRole"), profileAvatar: document.querySelector("#profileAvatar"), profileLogoutBtn: document.querySelector("#profileLogoutBtn"), + profileForm: document.querySelector("#profileForm"), + profileDisplayName: document.querySelector("#profileDisplayName"), + profileEmail: document.querySelector("#profileEmail"), + profileMessage: document.querySelector("#profileMessage"), + passwordForm: document.querySelector("#passwordForm"), + currentPassword: document.querySelector("#currentPassword"), + newPassword: document.querySelector("#newPassword"), + confirmPassword: document.querySelector("#confirmPassword"), + passwordMessage: document.querySelector("#passwordMessage"), + logoutAllBtn: document.querySelector("#logoutAllBtn"), luciStateDialog: document.querySelector("#luciStateDialog"), luciStateCode: document.querySelector("#luciStateCode"), luciStateTitle: document.querySelector("#luciStateTitle"), @@ -281,26 +291,53 @@ async function api(path, options = {}) { function showLogin(message = "") { els.appShell.classList.add("is-hidden"); + els.appShell.hidden = true; + els.appShell.setAttribute("aria-hidden", "true"); els.loginView.classList.remove("is-hidden"); + els.loginView.hidden = false; + els.loginView.removeAttribute("aria-hidden"); els.loginError.textContent = message; els.loginPassword.value = ""; + if (message) document.querySelector("#login")?.scrollIntoView({ behavior: "smooth", block: "center" }); } function showApp(user) { state.user = user || null; state.username = user && user.username ? user.username : "operator"; - els.operatorName.textContent = state.username; + const accountName = user && user.display_name ? user.display_name : state.username; + els.operatorName.textContent = accountName; els.profileUsername.textContent = state.username; els.profileRole.textContent = user && user.role === "admin" ? "Администратор" : "Пользователь"; - const initial = state.username.trim().charAt(0).toUpperCase() || "О"; + const initial = accountName.trim().charAt(0).toUpperCase() || "О"; els.profileAvatar.textContent = initial; document.querySelector(".operator-avatar").textContent = initial; els.addUserBtn.classList.toggle("is-hidden", !user || user.role !== "admin"); els.loginView.classList.add("is-hidden"); + els.loginView.hidden = true; + els.loginView.setAttribute("aria-hidden", "true"); els.appShell.classList.remove("is-hidden"); + els.appShell.hidden = false; + els.appShell.removeAttribute("aria-hidden"); els.loginError.textContent = ""; } +function formatLoadAverage(value) { + const values = String(value || "").trim().split(/\s+/).slice(0, 3).map(Number); + if (values.length !== 3 || values.some((item) => !Number.isFinite(item))) return "-"; + return values.map((item) => item.toFixed(2)).join(" · "); +} + +function formatUptime(value) { + const seconds = Number.parseFloat(String(value || "").trim().split(/\s+/)[0]); + if (!Number.isFinite(seconds) || seconds < 0) return "-"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days} дн. ${hours} ч.`; + if (hours > 0) return `${hours} ч. ${minutes} мин.`; + return `${minutes} мин.`; +} + async function checkSession() { try { const me = await api("/api/auth/me"); @@ -640,6 +677,8 @@ function renderDevices() { const item = document.createElement("button"); item.type = "button"; item.className = `device-item ${device.id === state.selectedDeviceId ? "is-selected" : ""}`; + item.dataset.deviceId = device.id; + item.setAttribute("aria-label", `Открыть роутер ${displayName}`); item.innerHTML = ` ${device.online ? "На связи" : "Не на связи"} @@ -675,13 +714,17 @@ function renderDeviceDetail(device) { els.appShell.classList.toggle("has-selected-device", Boolean(device)); if (!device) { els.fleetView.classList.remove("is-hidden"); + els.fleetView.hidden = false; els.deviceView.classList.add("is-hidden"); + els.deviceView.hidden = true; els.pageTitle.textContent = "Объекты"; return; } els.fleetView.classList.add("is-hidden"); + els.fleetView.hidden = true; els.deviceView.classList.remove("is-hidden"); + els.deviceView.hidden = false; renderDeviceTab(); const displayName = deviceDisplayName(device); els.pageTitle.textContent = displayName; @@ -690,8 +733,8 @@ function renderDeviceDetail(device) { els.deviceBadge.textContent = device.online ? "На связи" : "Не на связи"; els.deviceBadge.className = `badge ${device.online ? "online" : "offline"}`; els.lastSeen.textContent = formatDate(device.last_seen_at); - els.loadAvg.textContent = device.metrics && device.metrics.loadavg ? device.metrics.loadavg : "-"; - els.uptime.textContent = device.metrics && device.metrics.uptime ? device.metrics.uptime : "-"; + els.loadAvg.textContent = formatLoadAverage(device.metrics && device.metrics.loadavg); + els.uptime.textContent = formatUptime(device.metrics && device.metrics.uptime); els.defaultRoute.textContent = device.inventory && device.inventory.default_route ? device.inventory.default_route : "-"; els.wanIp.textContent = device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "-"; els.memoryUsage.textContent = formatMemory(device.metrics && device.metrics.memory); @@ -1048,10 +1091,16 @@ function selectDeviceTab(tab) { function renderDeviceTab() { for (const button of document.querySelectorAll(".device-tab")) { - button.classList.toggle("is-active", button.dataset.deviceTabTarget === state.deviceTab); + const active = button.dataset.deviceTabTarget === state.deviceTab; + button.classList.toggle("is-active", active); + button.setAttribute("aria-selected", String(active)); + button.tabIndex = active ? 0 : -1; } for (const section of document.querySelectorAll("[data-device-tab]")) { - section.classList.toggle("is-hidden", section.dataset.deviceTab !== state.deviceTab); + const active = section.dataset.deviceTab === state.deviceTab; + section.classList.toggle("is-hidden", !active); + section.hidden = !active; + section.setAttribute("aria-hidden", String(!active)); } } @@ -1151,6 +1200,11 @@ async function openDeviceArea(tab, selector, route) { function showProfile() { state.previousMobileRoute = state.mobileRoute === "profile" ? "fleet" : state.mobileRoute; setMobileRoute("profile"); + els.profileDisplayName.value = state.user && state.user.display_name ? state.user.display_name : ""; + els.profileEmail.value = state.user && state.user.email ? state.user.email : ""; + setFormMessage(els.profileMessage, ""); + setFormMessage(els.passwordMessage, ""); + els.passwordForm.reset(); if (!els.profileDialog.open) els.profileDialog.showModal(); } @@ -1159,6 +1213,47 @@ function closeProfile() { setMobileRoute(state.previousMobileRoute || "fleet"); } +function setFormMessage(element, message, tone = "") { + element.textContent = message; + element.className = `form-message${tone ? ` is-${tone}` : ""}`; +} + +async function saveProfile() { + setFormMessage(els.profileMessage, "Сохраняем…"); + const response = await api("/api/auth/profile", { + method: "PATCH", + body: JSON.stringify({ + display_name: els.profileDisplayName.value.trim(), + email: els.profileEmail.value.trim(), + }), + }); + showApp(response.user); + setFormMessage(els.profileMessage, "Профиль сохранён", "success"); +} + +async function changePassword() { + setFormMessage(els.passwordMessage, ""); + if (els.newPassword.value !== els.confirmPassword.value) { + setFormMessage(els.passwordMessage, "Новые пароли не совпадают", "error"); + return; + } + await api("/api/auth/change-password", { + method: "POST", + body: JSON.stringify({ current_password: els.currentPassword.value, new_password: els.newPassword.value }), + }); + els.passwordForm.reset(); + setFormMessage(els.passwordMessage, "Пароль изменён. Остальные сессии завершены.", "success"); +} + +async function logoutAll() { + if (!window.confirm("Завершить все активные сессии, включая эту?")) return; + await api("/api/auth/logout-all", { method: "POST" }); + closeProfile(); + state.devices = []; + state.user = null; + showLogin("Все сессии завершены. Войдите снова."); +} + async function runLuCIPrimaryAction() { const activeSession = state.remoteSessions.find((session) => session.status === "active" && session.luci_port); if (els.luciStateDialog.open) els.luciStateDialog.close(); @@ -1893,6 +1988,15 @@ els.profileLogoutBtn.addEventListener("click", () => { closeProfile(); logout().catch(reportError); }); +els.profileForm.addEventListener("submit", (event) => { + event.preventDefault(); + saveProfile().catch((error) => setFormMessage(els.profileMessage, error.message, "error")); +}); +els.passwordForm.addEventListener("submit", (event) => { + event.preventDefault(); + changePassword().catch((error) => setFormMessage(els.passwordMessage, error.message, "error")); +}); +els.logoutAllBtn.addEventListener("click", () => logoutAll().catch(reportError)); els.closeLuciStateBtn.addEventListener("click", () => els.luciStateDialog.close()); els.luciStatePrimaryBtn.addEventListener("click", () => runLuCIPrimaryAction().catch(reportError)); diff --git a/web/index.html b/web/index.html index 2d149e3..4e33bba 100644 --- a/web/index.html +++ b/web/index.html @@ -10,35 +10,78 @@ - + -
- - + diff --git a/web/styles.css b/web/styles.css index b14d0cd..ecfd0f0 100644 --- a/web/styles.css +++ b/web/styles.css @@ -15,6 +15,253 @@ --shadow: none; } +/* Public landing and account management. */ +[hidden] { + display: none !important; +} + +.landing-view { + min-height: 100vh; + overflow: hidden; + background: + radial-gradient(circle at 16% 8%, rgb(39 182 230 / 15%), transparent 30rem), + radial-gradient(circle at 88% 28%, rgb(67 96 246 / 11%), transparent 32rem), + #071019; +} + +.landing-header, +.landing-hero, +.landing-features, +.landing-security, +.landing-login-section, +.landing-footer { + width: min(1160px, calc(100% - 40px)); + margin-inline: auto; +} + +.landing-header { + display: flex; + min-height: 80px; + align-items: center; + justify-content: space-between; +} + +.landing-brand { + display: flex; + gap: 11px; + align-items: center; + color: var(--text); + text-decoration: none; +} + +.landing-brand img, +.brand-mark { + object-fit: contain; +} + +.landing-brand span { + display: grid; + gap: 1px; +} + +.landing-brand small { + color: var(--muted); + font-size: 10px; + letter-spacing: .08em; + text-transform: uppercase; +} + +.landing-header nav { + display: flex; + gap: 26px; + align-items: center; +} + +.landing-header nav a { + color: var(--muted); + font-size: 14px; + font-weight: 650; + text-decoration: none; +} + +.landing-header nav a:hover { color: var(--text); } +.landing-header .landing-login-link { + border: 1px solid rgb(39 182 230 / 45%); + border-radius: 10px; + color: var(--text); + padding: 10px 16px; +} + +.landing-hero { + display: grid; + min-height: 650px; + grid-template-columns: minmax(0, 1.02fr) minmax(460px, .98fr); + gap: 70px; + align-items: center; + padding-block: 70px 90px; +} + +.landing-kicker { + display: inline-flex; + gap: 8px; + align-items: center; + color: #a7dcf0; + font-size: 13px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.landing-kicker i { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--good); + box-shadow: 0 0 0 5px rgb(85 214 135 / 12%); +} + +.landing-copy h1 { + max-width: 680px; + margin: 22px 0; + font-size: clamp(44px, 6vw, 72px); + letter-spacing: -.055em; + line-height: .98; +} + +.landing-copy h1 em { color: var(--accent); font-style: normal; } +.landing-copy > p { + max-width: 650px; + color: #acbaca; + font-size: 18px; + line-height: 1.65; +} + +.landing-actions { display: flex; gap: 12px; margin-top: 30px; } +.landing-actions a { + display: inline-flex; + min-height: 50px; + align-items: center; + border-radius: 12px; + font-weight: 750; + padding: 0 20px; + text-decoration: none; +} +.landing-primary { background: var(--accent); color: #fff; } +.landing-secondary { border: 1px solid var(--line); color: var(--text); } +.landing-trust { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 30px; color: var(--muted); font-size: 12px; } +.landing-trust b { color: var(--good); } + +.landing-console { + position: relative; + border: 1px solid rgb(59 88 115 / 75%); + border-radius: 22px; + background: linear-gradient(145deg, rgb(18 33 48 / 97%), rgb(9 20 30 / 97%)); + box-shadow: 0 35px 100px rgb(0 0 0 / 45%); + padding: 18px; + transform: perspective(1000px) rotateY(-3deg) rotateX(2deg); +} + +.landing-console::after { + content: ""; + position: absolute; + z-index: -1; + inset: 12% -10% -12% 18%; + background: rgb(39 182 230 / 17%); + filter: blur(60px); +} + +.console-top { display: flex; gap: 6px; align-items: center; border-bottom: 1px solid var(--line); padding: 0 0 14px; } +.console-top span { width: 8px; height: 8px; border-radius: 50%; background: #496075; } +.console-top small { margin-left: auto; color: var(--muted); font: 11px ui-monospace, monospace; } +.console-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding-block: 16px; } +.console-summary div { display: grid; gap: 7px; border: 1px solid var(--line); border-radius: 12px; background: rgb(255 255 255 / 2%); padding: 13px; } +.console-summary small { color: var(--muted); } +.console-summary strong { font-size: 21px; } +.console-summary .warn { color: var(--warn); } +.console-router { display: grid; grid-template-columns: 10px 1fr auto; gap: 12px; align-items: center; border-top: 1px solid var(--line); padding: 17px 8px; } +.console-router i { width: 9px; height: 9px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 5px rgb(85 214 135 / 10%); } +.console-router span { display: grid; gap: 4px; } +.console-router small { color: var(--muted); } +.console-router b { color: var(--good); font-size: 12px; } +.console-router.warning i { background: var(--warn); } +.console-router.warning b { color: var(--warn); } + +.landing-features { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + padding-block: 50px 100px; +} +.landing-features article { border: 1px solid var(--line); border-radius: 18px; background: rgb(15 26 38 / 72%); padding: 26px; } +.landing-features article > span { color: var(--accent); font: 700 12px ui-monospace, monospace; } +.landing-features h2 { margin: 24px 0 10px; font-size: 20px; } +.landing-features p { margin: 0; color: var(--muted); line-height: 1.6; } +.landing-security { display: grid; grid-template-columns: 1.15fr .85fr; gap: 60px; align-items: center; border-block: 1px solid var(--line); padding-block: 70px; } +.landing-security h2 { max-width: 650px; margin: 10px 0 0; font-size: clamp(28px, 4vw, 42px); line-height: 1.12; } +.landing-security ul { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; } +.landing-security li::before { content: "✓"; color: var(--good); margin-right: 10px; } +.landing-login-section { display: grid; grid-template-columns: 1fr 420px; gap: 70px; align-items: center; padding-block: 100px; } +.landing-login-section h2 { margin: 9px 0; font-size: 38px; } +.landing-login-section > div p { max-width: 500px; color: var(--muted); line-height: 1.6; } +.landing-login-section .login-form { width: 100%; } +.landing-footer { display: flex; justify-content: space-between; border-top: 1px solid var(--line); color: var(--muted); padding-block: 26px; } + +.profile-dialog { max-height: min(820px, calc(100dvh - 32px)); overflow-y: auto; } +.profile-form { display: grid; gap: 12px; border-top: 1px solid var(--line); padding-top: 18px; } +.profile-form label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } +.profile-section-heading { display: grid; gap: 3px; } +.profile-section-heading span { color: var(--muted); font-size: 12px; } +.profile-security-form { grid-template-columns: 1fr 1fr; } +.profile-security-form .profile-section-heading, +.profile-security-form label:first-of-type, +.profile-security-form button, +.profile-security-form .form-message { grid-column: 1 / -1; } +.form-message { min-height: 18px; margin: 0; color: var(--muted); font-size: 12px; } +.form-message.is-error { color: var(--bad); } +.form-message.is-success { color: var(--good); } +.profile-session-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; border-top: 1px solid var(--line); padding-top: 18px; } + +@media (max-width: 900px) { + .landing-header nav a:not(.landing-login-link) { display: none; } + .landing-hero { grid-template-columns: 1fr; gap: 45px; padding-block: 60px 80px; } + .landing-console { max-width: 620px; transform: none; } + .landing-features { grid-template-columns: 1fr; padding-bottom: 70px; } + .landing-security, + .landing-login-section { grid-template-columns: 1fr; gap: 36px; } + .landing-login-section { padding-block: 70px; } + .landing-login-section .login-form { max-width: 520px; } +} + +@media (max-width: 560px) { + .landing-header, + .landing-hero, + .landing-features, + .landing-security, + .landing-login-section, + .landing-footer { width: min(100% - 28px, 1160px); } + .landing-header { min-height: 68px; } + .landing-brand small { display: none; } + .landing-hero { min-height: auto; padding-block: 54px 70px; } + .landing-copy h1 { font-size: 43px; } + .landing-copy > p { font-size: 16px; } + .landing-actions { display: grid; } + .landing-actions a { justify-content: center; } + .landing-trust { display: grid; gap: 9px; } + .landing-console { margin-inline: -3px; padding: 12px; } + .console-summary { grid-template-columns: 1fr 1fr; } + .console-summary div:last-child { display: none; } + .console-router { grid-template-columns: 9px 1fr; } + .console-router b { display: none; } + .landing-features { padding-top: 25px; } + .landing-security { padding-block: 55px; } + .landing-login-section h2 { font-size: 31px; } + .landing-login-section .login-form { padding: 20px; } + .landing-footer { display: grid; gap: 5px; } + .profile-security-form, + .profile-session-actions { grid-template-columns: 1fr; } + .profile-security-form > * { grid-column: 1 !important; } +} + * { box-sizing: border-box; } @@ -2144,6 +2391,7 @@ select { width: auto; } } + dialog { border: 0; border-radius: 16px; @@ -2848,3 +3096,35 @@ select:focus-visible, border-bottom: 0; } } + +@media (min-width: 721px) and (max-width: 1300px) { + .fleet-table-head, + .device-item { + grid-template-columns: 105px minmax(160px, 1.2fr) minmax(140px, 1fr) minmax(120px, .9fr) 70px 88px 24px; + min-width: 0; + } + + .fleet-table-head > :nth-child(7), + .device-item > :nth-child(7) { + display: none; + } +} + +@media (max-width: 720px) { + .device-tabs { + display: flex; + gap: 5px; + overflow-x: auto; + scroll-snap-type: x proximity; + } + + .device-tab { + min-width: 104px; + flex: 0 0 auto; + scroll-snap-align: start; + } + + .device-meta { + overflow-wrap: anywhere; + } +}