From 64beeba8bedffd753c79d0a4f252cc95cf01e350 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 18:42:14 -0500 Subject: [PATCH] feat(ui): add user settings and refine dashboard experience --- internal/app/app.go | 4 + internal/db/db.go | 2 + internal/handlers/handlers.go | 364 ++++++++++++++++++++-- internal/models/models.go | 1 + internal/services/node.go | 2 + internal/services/repository.go | 29 +- internal/views/layouts/app.gohtml | 47 +-- internal/views/layouts/base.gohtml | 3 + internal/views/pages/automations.gohtml | 148 ++++----- internal/views/pages/console.gohtml | 6 +- internal/views/pages/groups.gohtml | 22 +- internal/views/pages/login.gohtml | 10 +- internal/views/pages/login_otp.gohtml | 4 +- internal/views/pages/node.gohtml | 53 ++-- internal/views/pages/register.gohtml | 12 +- internal/views/pages/settings.gohtml | 143 +++++---- internal/views/pages/setup_otp.gohtml | 32 +- internal/views/pages/uptime.gohtml | 174 ++++++----- internal/views/pages/user_settings.gohtml | 192 ++++++++++++ web/static/css/app.css | 247 +++++++++++---- web/static/img/favicon-rounded.png | Bin 0 -> 38663 bytes web/static/js/app.js | 169 ++++++++-- 22 files changed, 1255 insertions(+), 409 deletions(-) create mode 100644 internal/views/pages/user_settings.gohtml create mode 100644 web/static/img/favicon-rounded.png diff --git a/internal/app/app.go b/internal/app/app.go index f76f767..fdfc6c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -98,7 +98,11 @@ func New() (*App, error) { protected.Get("/automations", handler.AutomationsPage) protected.Get("/uptime", handler.UptimePage) protected.Get("/settings", handler.SettingsPage) + protected.Get("/settings/user", handler.UserSettingsPage) protected.Post("/settings/theme", handler.UpdateTheme) + protected.Post("/settings/user/theme", handler.UpdateUserTheme) + protected.Post("/settings/user/password", handler.UpdateUserPassword) + protected.Post("/settings/user/2fa/{action}", handler.UpdateUserOTP) protected.Group(func(editor chi.Router) { editor.Use(localmiddleware.RequireRole(models.RoleEditor)) diff --git a/internal/db/db.go b/internal/db/db.go index cb89734..1e650a4 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -57,6 +57,7 @@ func migrate(ctx context.Context, database *sql.DB) error { role TEXT NOT NULL, otp_secret TEXT NOT NULL, otp_enabled BOOLEAN NOT NULL DEFAULT 0, + theme_mode TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (organization_id) REFERENCES organizations(id) );`, @@ -185,6 +186,7 @@ func migrate(ctx context.Context, database *sql.DB) error { alterStatements := []string{ `ALTER TABLE organizations ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'dark';`, + `ALTER TABLE users ADD COLUMN theme_mode TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN tag TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN package_manager TEXT NOT NULL DEFAULT '';`, `ALTER TABLE nodes ADD COLUMN architecture TEXT NOT NULL DEFAULT '';`, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 05cb05e..d20c8aa 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -54,10 +54,28 @@ type nodePageData struct { } type settingsData struct { - ThemeVariables template.CSS - CurrentTheme string - CurrentMode string - Runs []models.CommandRun + NodeCount int + GroupCount int + JobCount int + UserCount int + Logs []settingsCommandLog +} + +type settingsCommandLog struct { + Target string + StartedAt time.Time + Status string + CommandText string + Output string +} + +type userSettingsData struct { + CurrentMode string + OTPEnabled bool + OTPSecret string + OTPQRCode string + Message string + Error string } type jobsPageData struct { @@ -70,6 +88,7 @@ type jobsPageData struct { type uptimePageData struct { Summary uptimeSummary + Chart uptimeChartData Periods []uptimePeriodRow Monitors []uptimeMonitorCard Incidents []models.UptimeIncident @@ -101,6 +120,26 @@ type uptimeMonitorCard struct { RecentChecks []models.UptimeCheck } +type uptimeChartPoint struct { + Label string + Availability float64 + AvailabilityText string + UpCount int + DownCount int + TotalCount int + AvailabilityHeight int + X float64 + Y float64 + ShowLabel bool +} + +type uptimeChartData struct { + Points []uptimeChartPoint + LabelsJSON template.JS + ValuesJSON template.JS + PointColorsJSON template.JS +} + func New(repo *services.Repository, auth *services.AuthService, sessions *services.SessionService, nodes *services.NodeService, renderer *views.Renderer, org models.Organization, baseURL string) *Handler { return &Handler{ repo: repo, @@ -748,17 +787,43 @@ func (h *Handler) CreateAutomation(w http.ResponseWriter, r *http.Request) { func (h *Handler) SettingsPage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) - org := h.currentOrganization(r.Context()) - currentMode := normalizeMode(org.ThemeMode) - currentTheme := normalizeTheme(org.Theme, currentMode) - h.render(w, r, "settings", "Theme System", settingsData{ - ThemeVariables: template.CSS(themePreview), - CurrentTheme: currentTheme, - CurrentMode: currentMode, - Runs: h.settingsRuns(r.Context()), + nodes, _ := h.nodes.ListNodes(r.Context(), h.org.ID) + groups, _ := h.repo.ListGroups(r.Context(), h.org.ID) + jobs, _ := h.nodes.ListAutomations(r.Context(), h.org.ID) + userCount, _ := h.repo.CountUsers(r.Context()) + h.render(w, r, "settings", "Settings", settingsData{ + NodeCount: len(nodes), + GroupCount: len(groups), + JobCount: len(jobs), + UserCount: userCount, + Logs: h.settingsLogs(r.Context()), }, user) } +func (h *Handler) UserSettingsPage(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + data := userSettingsData{ + CurrentMode: effectiveThemeMode(user, h.currentOrganization(r.Context())), + OTPEnabled: user.OTPEnabled, + Message: strings.TrimSpace(r.URL.Query().Get("message")), + Error: strings.TrimSpace(r.URL.Query().Get("error")), + } + + if !user.OTPEnabled { + uri := h.auth.BuildOTPURI(user.Email, user.OTPSecret) + png, _ := qrcode.Encode(uri, qrcode.Medium, 256) + data.OTPSecret = user.OTPSecret + data.OTPQRCode = "data:image/png;base64," + encodeBase64(png) + } + + h.render(w, r, "user_settings", "User Settings", data, user) +} + func (h *Handler) UptimePage(w http.ResponseWriter, r *http.Request) { user := localmiddleware.CurrentUser(r) h.render(w, r, "uptime", "Uptime", h.uptimePageData(r.Context()), user) @@ -800,6 +865,122 @@ func (h *Handler) UpdateTheme(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings", http.StatusSeeOther) } +func (h *Handler) UpdateUserTheme(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther) + return + } + + mode := strings.TrimSpace(r.FormValue("mode")) + if !isAllowedMode(mode) { + http.Redirect(w, r, "/settings/user?error=Invalid+theme+mode", http.StatusSeeOther) + return + } + + if err := h.repo.UpdateUserThemeMode(r.Context(), user.ID, mode); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+save+theme", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/settings/user?message=Theme+updated", http.StatusSeeOther) +} + +func (h *Handler) UpdateUserPassword(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther) + return + } + + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + if h.auth.ComparePassword(user.PasswordHash, currentPassword) != nil { + http.Redirect(w, r, "/settings/user?error=Current+password+is+incorrect", http.StatusSeeOther) + return + } + if len(newPassword) < 8 { + http.Redirect(w, r, "/settings/user?error=New+password+must+be+at+least+8+characters", http.StatusSeeOther) + return + } + if newPassword != confirmPassword { + http.Redirect(w, r, "/settings/user?error=Passwords+do+not+match", http.StatusSeeOther) + return + } + + hash, err := h.auth.HashPassword(newPassword) + if err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+update+password", http.StatusSeeOther) + return + } + if err := h.repo.UpdateUserPassword(r.Context(), user.ID, hash); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+save+password", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/settings/user?message=Password+updated", http.StatusSeeOther) +} + +func (h *Handler) UpdateUserOTP(w http.ResponseWriter, r *http.Request) { + user := localmiddleware.CurrentUser(r) + if user == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + action := chi.URLParam(r, "action") + switch action { + case "disable": + if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, false); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+disable+2FA", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/user?message=2FA+disabled", http.StatusSeeOther) + return + case "reset": + key, err := h.auth.NewOTP(user.Email) + if err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther) + return + } + if err := h.repo.UpdateUserOTP(r.Context(), user.ID, key.Secret(), false); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+reset+2FA", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/user?message=Scan+the+new+QR+code+to+enable+2FA", http.StatusSeeOther) + return + case "enable": + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings/user?error=Bad+request", http.StatusSeeOther) + return + } + if !h.auth.VerifyOTP(user.OTPSecret, strings.TrimSpace(r.FormValue("code"))) { + http.Redirect(w, r, "/settings/user?error=Invalid+OTP+code", http.StatusSeeOther) + return + } + if err := h.repo.UpdateUserOTP(r.Context(), user.ID, user.OTPSecret, true); err != nil { + http.Redirect(w, r, "/settings/user?error=Failed+to+enable+2FA", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/settings/user?message=2FA+enabled", http.StatusSeeOther) + return + default: + http.NotFound(w, r) + } +} + func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title string, content any, user ...*models.User) { var currentUser *models.User if len(user) > 0 { @@ -812,7 +993,7 @@ func (h *Handler) render(w http.ResponseWriter, r *http.Request, page, title str org := h.currentOrganization(r.Context()) groups, _ := h.repo.ListGroups(r.Context(), org.ID) tags, _ := h.repo.ListTags(r.Context(), org.ID) - mode := normalizeMode(org.ThemeMode) + mode := effectiveThemeMode(currentUser, org) theme := normalizeTheme(org.Theme, mode) h.renderer.Render(w, page, views.ViewData{ @@ -912,6 +1093,7 @@ func (h *Handler) uptimePageData(ctx context.Context) uptimePageData { return uptimePageData{ Summary: summary, + Chart: buildUptimeChart(checksByMonitor), Periods: periods, Monitors: cards, Incidents: incidents, @@ -1041,6 +1223,92 @@ func reverseChecks(checks []models.UptimeCheck) []models.UptimeCheck { return reversed } +func buildUptimeChart(checksByMonitor map[int64][]models.UptimeCheck) uptimeChartData { + type bucket struct { + up int + down int + total int + } + + buckets := map[time.Time]*bucket{} + for _, checks := range checksByMonitor { + for _, check := range checks { + minute := check.CheckedAt.Truncate(time.Minute) + if buckets[minute] == nil { + buckets[minute] = &bucket{} + } + buckets[minute].total++ + if check.Status == "up" { + buckets[minute].up++ + } else { + buckets[minute].down++ + } + } + } + + if len(buckets) == 0 { + return uptimeChartData{} + } + + timestamps := make([]time.Time, 0, len(buckets)) + for ts := range buckets { + timestamps = append(timestamps, ts) + } + sort.Slice(timestamps, func(i, j int) bool { + return timestamps[i].Before(timestamps[j]) + }) + if len(timestamps) > 30 { + timestamps = timestamps[len(timestamps)-30:] + } + + points := make([]uptimeChartPoint, 0, len(timestamps)) + for _, ts := range timestamps { + current := buckets[ts] + availability := 0.0 + if current.total > 0 { + availability = float64(current.up) * 100 / float64(current.total) + } + height := int(availability) + if current.total > 0 && height < 8 { + height = 8 + } + points = append(points, uptimeChartPoint{ + Label: ts.Format("15:04"), + Availability: availability, + AvailabilityText: fmt.Sprintf("%.0f%%", availability), + UpCount: current.up, + DownCount: current.down, + TotalCount: current.total, + AvailabilityHeight: height, + }) + } + + labels := make([]string, 0, len(points)) + values := make([]float64, 0, len(points)) + pointColors := make([]string, 0, len(points)) + for i := range points { + points[i].ShowLabel = i == 0 || i == len(points)-1 || i == len(points)/2 || i%6 == 0 + labels = append(labels, points[i].Label) + values = append(values, points[i].Availability) + if points[i].DownCount > 0 { + pointColors = append(pointColors, "#ef4444") + } else { + pointColors = append(pointColors, "rgb(var(--color-primary-500))") + } + } + + labelsJSON, _ := json.Marshal(labels) + valuesJSON, _ := json.Marshal(values) + pointColorsJSON, _ := json.Marshal(pointColors) + + return uptimeChartData{ + Points: points, + LabelsJSON: template.JS(labelsJSON), + ValuesJSON: template.JS(valuesJSON), + PointColorsJSON: template.JS(pointColorsJSON), + } +} + func shellForPage(page string) string { switch page { case "login", "login_otp", "register": @@ -1079,12 +1347,56 @@ func (h *Handler) nodesRunQuery(ctx context.Context, nodeID int64) (*sql.Rows, e `, nodeID) } -func (h *Handler) settingsRuns(ctx context.Context) []models.CommandRun { +func (h *Handler) settingsLogs(ctx context.Context) []settingsCommandLog { runs, err := h.nodes.ListCommandHistory(ctx, h.org.ID) if err != nil { return nil } - return runs + if len(runs) == 0 { + return nil + } + + var logs []settingsCommandLog + for _, run := range runs { + target := run.NodeName + if strings.TrimSpace(run.GroupName) != "" { + target = run.GroupName + " / " + target + } + commandText := strings.TrimSpace(run.CommandText) + if commandText == "" { + commandText = strings.TrimSpace(run.Action) + } + + if len(logs) > 0 { + last := &logs[len(logs)-1] + if last.Target == target && last.Status == run.Status && last.StartedAt.Equal(run.StartedAt) { + if commandText != "" && !strings.Contains(last.CommandText, commandText) { + if last.CommandText != "" { + last.CommandText += "\n" + } + last.CommandText += commandText + } + output := strings.TrimSpace(run.Output) + if output != "" && !strings.Contains(last.Output, output) { + if last.Output != "" { + last.Output += "\n\n" + } + last.Output += output + } + continue + } + } + + logs = append(logs, settingsCommandLog{ + Target: target, + StartedAt: run.StartedAt, + Status: run.Status, + CommandText: commandText, + Output: strings.TrimSpace(run.Output), + }) + } + + return logs } func (h *Handler) nodesDB() *sql.DB { @@ -1099,21 +1411,6 @@ func encodeBase64(raw []byte) string { return base64.StdEncoding.EncodeToString(raw) } -const themePreview = ` -:root { - --color-primary-50: 236 253 245; - --color-primary-100: 209 250 229; - --color-primary-200: 167 243 208; - --color-primary-300: 110 231 183; - --color-primary-400: 52 211 153; - --color-primary-500: 16 185 129; - --color-primary-600: 5 150 105; - --color-primary-700: 4 120 87; - --color-primary-800: 6 95 70; - --color-primary-900: 6 78 59; - --color-primary-950: 2 44 34; -}` - func buildSchedule(triggerType, kind, hour, minute, weekday, intervalValue, intervalUnit string) string { if triggerType == "triggered" { return "" @@ -1174,6 +1471,13 @@ func normalizeMode(mode string) string { return "dark" } +func effectiveThemeMode(user *models.User, org models.Organization) string { + if user != nil && strings.TrimSpace(user.ThemeMode) != "" { + return normalizeMode(user.ThemeMode) + } + return normalizeMode(org.ThemeMode) +} + func saveKeyUpload(r *http.Request, field string) (string, error) { file, header, err := r.FormFile(field) if err != nil { diff --git a/internal/models/models.go b/internal/models/models.go index e338cac..3333159 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -27,6 +27,7 @@ type User struct { Role Role OTPSecret string OTPEnabled bool + ThemeMode string CreatedAt time.Time } diff --git a/internal/services/node.go b/internal/services/node.go index 72685d4..aafedb3 100644 --- a/internal/services/node.go +++ b/internal/services/node.go @@ -188,6 +188,8 @@ func (s *NodeService) EnsureUptimeMonitorForNode(ctx context.Context, node *mode organization_id = excluded.organization_id, name = excluded.name, target = excluded.target, + interval_seconds = 60, + enabled = 1, updated_at = CURRENT_TIMESTAMP `, node.OrganizationID, node.ID, name, target) return err diff --git a/internal/services/repository.go b/internal/services/repository.go index f66b4a4..4c888c0 100644 --- a/internal/services/repository.go +++ b/internal/services/repository.go @@ -71,9 +71,9 @@ func (r *Repository) CountUsers(ctx context.Context) (int, error) { func (r *Repository) CreateUser(ctx context.Context, user *models.User) error { result, err := r.db.ExecContext(ctx, ` - INSERT INTO users (organization_id, name, email, password_hash, role, otp_secret, otp_enabled) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, user.Organization, user.Name, user.Email, user.PasswordHash, user.Role, user.OTPSecret, user.OTPEnabled) + INSERT INTO users (organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, user.Organization, user.Name, user.Email, user.PasswordHash, user.Role, user.OTPSecret, user.OTPEnabled, user.ThemeMode) if err != nil { return err } @@ -85,12 +85,12 @@ func (r *Repository) CreateUser(ctx context.Context, user *models.User) error { func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { user := &models.User{} err := r.db.QueryRowContext(ctx, ` - SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, created_at + SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode, created_at FROM users WHERE email = ? `, email).Scan( &user.ID, &user.Organization, &user.Name, &user.Email, &user.PasswordHash, &user.Role, - &user.OTPSecret, &user.OTPEnabled, &user.CreatedAt, + &user.OTPSecret, &user.OTPEnabled, &user.ThemeMode, &user.CreatedAt, ) if err != nil { return nil, err @@ -102,12 +102,12 @@ func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*models. func (r *Repository) GetUserByID(ctx context.Context, id int64) (*models.User, error) { user := &models.User{} err := r.db.QueryRowContext(ctx, ` - SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, created_at + SELECT id, organization_id, name, email, password_hash, role, otp_secret, otp_enabled, theme_mode, created_at FROM users WHERE id = ? `, id).Scan( &user.ID, &user.Organization, &user.Name, &user.Email, &user.PasswordHash, &user.Role, - &user.OTPSecret, &user.OTPEnabled, &user.CreatedAt, + &user.OTPSecret, &user.OTPEnabled, &user.ThemeMode, &user.CreatedAt, ) if err != nil { return nil, err @@ -121,6 +121,21 @@ func (r *Repository) EnableUserOTP(ctx context.Context, id int64) error { return err } +func (r *Repository) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, id) + return err +} + +func (r *Repository) UpdateUserThemeMode(ctx context.Context, id int64, themeMode string) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET theme_mode = ? WHERE id = ?`, themeMode, id) + return err +} + +func (r *Repository) UpdateUserOTP(ctx context.Context, id int64, secret string, enabled bool) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET otp_secret = ?, otp_enabled = ? WHERE id = ?`, secret, enabled, id) + return err +} + func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error { var adminCount int if err := r.db.QueryRowContext(ctx, ` diff --git a/internal/views/layouts/app.gohtml b/internal/views/layouts/app.gohtml index 0d724fa..cd2b719 100644 --- a/internal/views/layouts/app.gohtml +++ b/internal/views/layouts/app.gohtml @@ -26,10 +26,6 @@ - {{else if eq .CurrentPath "/settings"}} - - Themes - {{else if eq .CurrentPath "/dashboard"}} @@ -145,13 +152,13 @@