Skip to content

Commit 08cb17e

Browse files
committed
feat(onboarding): implement first-run onboarding flow with welcome page and admin controls
1 parent adef4a4 commit 08cb17e

5 files changed

Lines changed: 304 additions & 93 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,12 @@ func main() {
522522
// (per-user → system → fallback). See feature/dashboard-gear F1.
523523
r.Get("/", h.RootRedirect)
524524

525+
// First-run onboarding (issue #49). Admin-only. The /welcome page
526+
// self-redirects to / once onboarding is complete (any box or
527+
// system gear enabled), so it's safe to leave reachable.
528+
r.Get("/welcome", h.WelcomePage)
529+
r.Post("/onboarding", h.OnboardingPost)
530+
525531
// Settings routes
526532
r.Route("/settings", func(r chi.Router) {
527533
// Settings menu page (accessible by all authenticated users)

gearbox/internal/framework/handler/login.go

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"net/url"
77

88
"github.com/sarg3nt/gearbox/internal/framework/database"
9-
"github.com/sarg3nt/gearbox/internal/framework/models"
109
"github.com/sarg3nt/gearbox/internal/framework/templates/pages"
1110
)
1211

@@ -150,17 +149,9 @@ func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) {
150149
return
151150
}
152151

153-
// If admin user and no HAProxy servers configured, redirect to server setup
154-
if user.Role == models.RoleAdmin {
155-
count, err := h.db.CountEnabledBoxes()
156-
if err != nil {
157-
h.logger.Error("Failed to count HAProxy servers", "error", err)
158-
} else if count == 0 {
159-
h.logger.Info("admin user logged in but no HAProxy servers configured, redirecting to setup", "email", email)
160-
http.Redirect(w, r, "/settings/boxes/new", http.StatusSeeOther)
161-
return
162-
}
163-
}
152+
// First-run onboarding is no longer forced at login time. If nothing is
153+
// configured, the user's resolved landing path will be "/", which
154+
// RootRedirect routes to /welcome. Issue #49.
164155

165156
// Redirect to return URL if provided, otherwise honor the per-user
166157
// default-landing-path with system fallback. The return URL must be a
@@ -176,9 +167,12 @@ func (h *Handler) LoginPost(w http.ResponseWriter, r *http.Request) {
176167
}
177168

178169
// RootRedirect handles GET /. Redirects authenticated users to their
179-
// default-landing-path (per-user → system → fallback). The chosen fallback
180-
// when nothing is configured is /haproxy when at least one box is configured,
181-
// otherwise /settings/boxes/new for a clean first-run experience.
170+
// default-landing-path (per-user → system → fallback). When nothing is
171+
// configured the fallback chain is:
172+
//
173+
// 1. Any box enabled → /haproxy
174+
// 2. Any system gear enabled → /home (today the only system gear)
175+
// 3. Otherwise → /welcome (first-run onboarding, issue #49)
182176
func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) {
183177
userID := ""
184178
if u, err := h.authManager.GetUser(r); err == nil && u != nil {
@@ -191,12 +185,15 @@ func (h *Handler) RootRedirect(w http.ResponseWriter, r *http.Request) {
191185
return
192186
}
193187

194-
// Fallback: send to haproxy if any box is configured, else to box setup.
195188
if count, err := h.db.CountEnabledBoxes(); err == nil && count > 0 {
196189
http.Redirect(w, r, "/haproxy", http.StatusSeeOther)
197190
return
198191
}
199-
http.Redirect(w, r, "/settings/boxes/new", http.StatusSeeOther)
192+
if homeGear, err := h.db.GetGear(database.SystemServerID, database.GearHome); err == nil && homeGear != nil && homeGear.Enabled {
193+
http.Redirect(w, r, "/home", http.StatusSeeOther)
194+
return
195+
}
196+
http.Redirect(w, r, "/welcome", http.StatusSeeOther)
200197
}
201198

202199
// Logout handles user logout.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
7+
"github.com/sarg3nt/gearbox/internal/framework/database"
8+
"github.com/sarg3nt/gearbox/internal/framework/templates/pages"
9+
)
10+
11+
// WelcomePage renders the first-run onboarding screen. Issue #49.
12+
//
13+
// Shown only while nothing has been configured — no enabled boxes and no
14+
// enabled system gears. Once the admin makes any choice, the welcome screen
15+
// is no longer the landing fallback and direct visits redirect away.
16+
func (h *Handler) WelcomePage(w http.ResponseWriter, r *http.Request) {
17+
user, err := h.authManager.GetUser(r)
18+
if err != nil {
19+
http.Redirect(w, r, "/login", http.StatusSeeOther)
20+
return
21+
}
22+
if !user.IsAdmin() {
23+
http.Error(w, "Forbidden", http.StatusForbidden)
24+
return
25+
}
26+
27+
if h.onboardingComplete() {
28+
http.Redirect(w, r, "/", http.StatusSeeOther)
29+
return
30+
}
31+
32+
systemGears, err := h.db.GetGears(database.SystemServerID)
33+
if err != nil {
34+
h.logger.Warn("failed to load system gears for welcome page", "error", err)
35+
systemGears = nil
36+
}
37+
// Only offer gears the admin hasn't already enabled. Onboarding is
38+
// one-shot; if a gear is enabled the page wouldn't be rendered at all
39+
// (see onboardingComplete above), so this filter is mostly defensive.
40+
disabled := make([]database.Gear, 0, len(systemGears))
41+
for _, g := range systemGears {
42+
if !g.Enabled {
43+
disabled = append(disabled, g)
44+
}
45+
}
46+
47+
csrfToken, _ := h.authManager.GetCSRFToken(r)
48+
errorMessage := r.URL.Query().Get("error")
49+
50+
component := pages.WelcomePage(user, disabled, csrfToken, errorMessage)
51+
if err := component.Render(r.Context(), w); err != nil {
52+
h.logger.Error("Failed to render welcome template", "error", err)
53+
http.Error(w, "Internal server error", http.StatusInternalServerError)
54+
}
55+
}
56+
57+
// OnboardingPost handles the welcome form. Two submit buttons share one form:
58+
//
59+
// - action=add-box → enable any checked system gears, then redirect to
60+
// /settings/boxes/new
61+
// - action=skip → enable any checked system gears, then redirect to /
62+
// and let RootRedirect's fallback chain pick the landing page
63+
//
64+
// If nothing is checked and the admin clicked "skip", the redirect lands back
65+
// on /welcome via RootRedirect (no decision made, no-op).
66+
func (h *Handler) OnboardingPost(w http.ResponseWriter, r *http.Request) {
67+
user, err := h.authManager.GetUser(r)
68+
if err != nil {
69+
http.Redirect(w, r, "/login", http.StatusSeeOther)
70+
return
71+
}
72+
if !user.IsAdmin() {
73+
http.Error(w, "Forbidden", http.StatusForbidden)
74+
return
75+
}
76+
77+
if err := h.authManager.ValidateCSRFToken(r); err != nil {
78+
http.Redirect(w, r, "/welcome?error="+url.QueryEscape("Invalid CSRF token"), http.StatusSeeOther)
79+
return
80+
}
81+
82+
if err := r.ParseForm(); err != nil {
83+
http.Redirect(w, r, "/welcome?error="+url.QueryEscape("Invalid form submission"), http.StatusSeeOther)
84+
return
85+
}
86+
87+
action := r.FormValue("action")
88+
checked := r.Form["gears"]
89+
90+
// Only enable gears that are actually system-scoped — defence in depth
91+
// against form tampering.
92+
for _, name := range checked {
93+
if !database.IsSystemGear(name) {
94+
h.logger.Warn("onboarding ignored non-system gear", "name", name, "user", user.ID)
95+
continue
96+
}
97+
if err := h.db.SetGearEnabled(database.SystemServerID, name, true, &user.ID); err != nil {
98+
h.logger.Error("failed to enable system gear during onboarding",
99+
"name", name, "error", err, "user", user.ID)
100+
http.Redirect(w, r, "/welcome?error="+url.QueryEscape("Failed to enable selected tools"), http.StatusSeeOther)
101+
return
102+
}
103+
h.logger.Info("onboarding enabled system gear", "name", name, "user", user.ID)
104+
}
105+
106+
if action == "add-box" {
107+
http.Redirect(w, r, "/settings/boxes/new", http.StatusSeeOther)
108+
return
109+
}
110+
// action == "skip" (or unknown) — let RootRedirect's chain decide.
111+
http.Redirect(w, r, "/", http.StatusSeeOther)
112+
}
113+
114+
// onboardingComplete reports whether the admin has finished onboarding —
115+
// at least one box is enabled, or at least one system-scoped gear is
116+
// enabled. Used as the one-shot guard for the welcome screen.
117+
func (h *Handler) onboardingComplete() bool {
118+
if count, err := h.db.CountEnabledBoxes(); err == nil && count > 0 {
119+
return true
120+
}
121+
if anyEnabled, err := h.anySystemGearEnabled(); err == nil && anyEnabled {
122+
return true
123+
}
124+
return false
125+
}
126+
127+
// anySystemGearEnabled reports whether any box-agnostic gear is enabled on
128+
// the system row.
129+
func (h *Handler) anySystemGearEnabled() (bool, error) {
130+
gears, err := h.db.GetGears(database.SystemServerID)
131+
if err != nil {
132+
return false, err
133+
}
134+
for _, g := range gears {
135+
if g.Enabled {
136+
return true, nil
137+
}
138+
}
139+
return false, nil
140+
}

gearbox/internal/framework/templates/pages/haproxy_settings.templ

Lines changed: 6 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,86 +9,16 @@ import (
99
"github.com/sarg3nt/gearbox/internal/framework/templates/layouts"
1010
)
1111

12-
// HAProxyServersPage renders the list of monitored servers.
13-
// When no servers exist, it uses a minimal layout without the sidebar (like login page).
12+
// HAProxyBoxesPage renders the list of monitored boxes. First-run onboarding
13+
// is owned by the /welcome screen (issue #49); this page always uses the
14+
// standard sidebar layout and shows an inline empty-state when the list is
15+
// zero.
1416
templ HAProxyBoxesPage(user *models.User, servers []*database.BoxDB) {
15-
if len(servers) == 0 {
16-
@HAProxyBoxesPageNoSidebar(user)
17-
} else {
18-
@layouts.Base("Boxes", user, "/settings") {
19-
@HAProxyBoxesPageContent(user, servers)
20-
}
17+
@layouts.Base("Boxes", user, "/settings") {
18+
@HAProxyBoxesPageContent(user, servers)
2119
}
2220
}
2321

24-
// HAProxyServersPageNoSidebar renders the boxes page without sidebar when no servers configured.
25-
templ HAProxyBoxesPageNoSidebar(user *models.User) {
26-
<!DOCTYPE html>
27-
<html lang="en" class="h-full">
28-
<head>
29-
<meta charset="UTF-8"/>
30-
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
31-
<title>Boxes - Gearbox</title>
32-
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
33-
if middleware.UseLocalAssets(ctx) {
34-
<script src="/static/js/vendor/tailwind.js"></script>
35-
} else {
36-
<script src="https://cdn.tailwindcss.com"></script>
37-
}
38-
<script>
39-
tailwind.config = { darkMode: 'class' }
40-
// Apply theme immediately
41-
function getThemePreference() {
42-
const stored = localStorage.getItem('theme');
43-
if (stored) return stored;
44-
return 'system';
45-
}
46-
function getEffectiveTheme() {
47-
const pref = getThemePreference();
48-
if (pref === 'system') {
49-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
50-
}
51-
return pref;
52-
}
53-
if (getEffectiveTheme() === 'dark') {
54-
document.documentElement.classList.add('dark');
55-
}
56-
</script>
57-
</head>
58-
<body class="h-full bg-gray-100 dark:bg-slate-900">
59-
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
60-
<div class="max-w-2xl w-full">
61-
<div class="text-center mb-8">
62-
<div class="flex justify-center mb-4">
63-
<img src="/favicon.svg" alt="Gearbox Logo" class="w-16 h-16"/>
64-
</div>
65-
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Gearbox</h1>
66-
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Real-time monitoring for HAProxy</p>
67-
</div>
68-
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-lg p-8 text-center">
69-
<svg class="mx-auto h-16 w-16 text-gray-400 mb-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
71-
</svg>
72-
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-3">No boxes configured</h2>
73-
<p class="text-gray-600 dark:text-gray-400 mb-6">
74-
Get started by adding your first monitored box.
75-
</p>
76-
<a
77-
href="/settings/boxes/new"
78-
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
79-
>
80-
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
81-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
82-
</svg>
83-
Add Box
84-
</a>
85-
</div>
86-
</div>
87-
</div>
88-
</body>
89-
</html>
90-
}
91-
9222
// HAProxyServersPageContent renders the main content of the boxes page.
9323
templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) {
9424
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">

0 commit comments

Comments
 (0)