Skip to content

Commit c975c49

Browse files
sarg3ntclaude
andauthored
feat: optional first-box onboarding with welcome screen (#57)
* feat(onboarding): implement first-run onboarding flow with welcome page and admin controls * fix(onboarding): welcome-flow polish from manual testing - Sidebar shows Home when it's the only enabled gear. The status middleware was short-circuiting on box-count==0 and never injecting system gears into the nav order; now system gears load first regardless of box state, and box-scoped gears are appended only when a box exists. - /settings/gears no longer 400s with plain "No servers configured" when no boxes are registered. Renders the Home gear plus a "No boxes configured" CTA card linking to /settings/boxes/new. Inline copy switched to "box" terminology to match issue #49. - Welcome screen uses the shared ui.Toggle slider instead of a bare <input type="checkbox">. ui.Toggle gained a value parameter so it can submit a specific value when multiple toggles share a name (multi-select checkbox group). CLAUDE.md adds "Toggle switches — never use a bare <input type=checkbox>" under UI Conventions so this stays the canonical pattern. - Home empty-state icon scaled up via new HomeIconClass(class) wrapper. Sidebar HomeIcon() unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(onboarding): address Copilot review on PR #57 - ui.Toggle: default an empty value to "on" inside the component (via toggleValue helper) so single-boolean callers submit a meaningful form value matching the HTML default. Previously the component emitted value="" verbatim, so r.FormValue(name) == "on" was always false even when checked. Multi-select groups still pass an explicit non-empty value as before. - InjectIntegrationStatus: restore fail-open semantics on box-gear DB load errors. Previously this PR set a partial gear status/order context (system gears only) on error, collapsing the sidebar instead of falling back to the default full-rendering branch. Now box-gear errors short-circuit before any context is set, matching the original behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent adef4a4 commit c975c49

12 files changed

Lines changed: 466 additions & 180 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,28 @@ await showAlertDialog({
200200

201201
Existing reference usages: [user-pages/admin-user-detail.js](static/js/user-pages/admin-user-detail.js), [user-pages/profile-management.js](static/js/user-pages/profile-management.js), [haproxy_config/editor.js](static/js/haproxy_config/editor.js).
202202

203+
### Toggle switches — never use a bare `<input type="checkbox">` in templates
204+
205+
For every boolean input in a `.templ` file — feature opt-ins, settings, "enable this gear", "show all", per-row enable/disable — use the shared slider component in [internal/framework/ui/toggle.templ](gearbox/internal/framework/ui/toggle.templ):
206+
207+
```templ
208+
import "github.com/sarg3nt/gearbox/internal/framework/ui"
209+
210+
// Single toggle (no inline label — pair with your own <label for=...>)
211+
@ui.Toggle("welcome-gear-home", "gears", "home", false, false)
212+
// args: id, name, value (submitted when checked), checked, disabled
213+
214+
// Toggle + label + description, stacked horizontally
215+
@ui.ToggleWithLabel("notify-email", "notify_email", "1", "Email notifications", "Send a digest each morning", true, false)
216+
```
217+
218+
**Rules of thumb:**
219+
220+
- The underlying input is `sr-only` but real — it submits with the form and respects `checked` / `disabled`. No JS required for plain forms.
221+
- Pass `value` when multiple toggles share a `name` (e.g., a multi-select checkbox group posting as `name="gears"`). Leave empty when a single boolean field submits as the default `"on"`.
222+
- For AJAX toggles that POST on change (no enclosing form), the per-row `gear-toggle` `<button role="switch">` pattern in [gears.templ](gearbox/internal/framework/templates/pages/gears.templ) is the established alternative — but for **anything inside a `<form>`**, use `@ui.Toggle`.
223+
- Never inline `peer-checked:after:...` Tailwind salads in a new template — that's a sign you should be calling `@ui.Toggle`. Existing inline copies in `overview.templ` and `admin_user_permissions.templ` are tech debt; migrate them when you're already editing those files.
224+
203225
## Key Constraints
204226

205227
### NEVER

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/gears.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,23 @@ func (h *Handler) GearsPage(w http.ResponseWriter, r *http.Request) {
3838
boxID = servers[0].ID
3939
}
4040

41-
if boxID == "" {
42-
http.Error(w, "No servers configured", http.StatusBadRequest)
43-
return
44-
}
45-
46-
// Ensure default gears exist for this server
47-
if err := h.db.EnsureServerGears(boxID); err != nil {
48-
h.logger.Error("Failed to ensure server gears", "error", err)
49-
}
41+
// boxID may be empty on a fresh install — the template renders an
42+
// "Add a Box" CTA in place of the per-box gear list. System-scoped
43+
// gears still render regardless.
44+
45+
var plugins []database.Gear
46+
if boxID != "" {
47+
// Ensure default gears exist for this box
48+
if err := h.db.EnsureServerGears(boxID); err != nil {
49+
h.logger.Error("Failed to ensure server gears", "error", err)
50+
}
5051

51-
plugins, err := h.db.GetGears(boxID)
52-
if err != nil {
53-
h.logger.Error("Failed to get gears", "error", err)
54-
http.Error(w, "Internal server error", http.StatusInternalServerError)
55-
return
52+
plugins, err = h.db.GetGears(boxID)
53+
if err != nil {
54+
h.logger.Error("Failed to get gears", "error", err)
55+
http.Error(w, "Internal server error", http.StatusInternalServerError)
56+
return
57+
}
5658
}
5759

5860
// Load system-scoped (box-agnostic) gears so they render alongside the

gearbox/internal/framework/handler/handler.go

Lines changed: 39 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ func (h *Handler) getDefaultServerID() string {
186186

187187
// InjectIntegrationStatus is middleware that adds integration status and user permissions to the request context.
188188
// This enables server-side conditional rendering of navigation items based on integration status and permissions.
189+
//
190+
// System-scoped gears (Home, etc. — keyed by database.SystemServerID) are
191+
// always loaded, even when no boxes are registered. Box-scoped gears load
192+
// from the default box if one exists; otherwise they're explicitly disabled
193+
// so the sidebar stays clean during first-run.
189194
func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler {
190195
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191196
ctx := r.Context()
@@ -196,69 +201,57 @@ func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler {
196201
ctx = auth.SetUserPermissions(ctx, perms)
197202
}
198203

199-
boxID := h.getDefaultServerID()
200-
if boxID != "" {
201-
// Get integrations with their enabled status and sort order
204+
status := make(map[string]bool)
205+
orderedIntegrations := make([]auth.SidebarIntegration, 0)
206+
207+
// System gears go first so they render at the head of the nav.
208+
systemGears, err := h.db.GetGears(database.SystemServerID)
209+
if err != nil {
210+
h.logger.Warn("failed to load system gears for sidebar", "error", err)
211+
}
212+
for _, sg := range systemGears {
213+
status[sg.Name] = sg.Enabled
214+
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
215+
Name: sg.Name,
216+
Enabled: sg.Enabled,
217+
SortOrder: sg.SortOrder,
218+
})
219+
}
220+
221+
if boxID := h.getDefaultServerID(); boxID != "" {
202222
integrations, err := h.db.GetGears(boxID)
203223
if err != nil {
204-
h.logger.Error("failed to get integrations for sidebar", "error", err)
205-
// Continue without status - sidebar will show all items (fail open)
224+
// Fail-open: a partial gear list could collapse the sidebar
225+
// to system-gears-only, hiding box features the user
226+
// actually has. Leave the gear-status/order context unset
227+
// so OrderedIntegrationLinks falls back to its default
228+
// (full) rendering branch.
229+
h.logger.Error("failed to get box integrations for sidebar", "error", err)
206230
next.ServeHTTP(w, r.WithContext(ctx))
207231
return
208232
}
209-
210-
// Merge in system-wide (box-agnostic) gears so the sidebar can show them.
211-
systemGears, err := h.db.GetGears(database.SystemServerID)
212-
if err != nil {
213-
h.logger.Warn("failed to load system gears for sidebar", "error", err)
214-
}
215-
216-
// Build status map for backward compatibility
217-
status := make(map[string]bool)
218233
for _, i := range integrations {
219234
status[i.Name] = i.Enabled
220-
}
221-
for _, i := range systemGears {
222-
status[i.Name] = i.Enabled
223-
}
224-
225-
// Build ordered list for sidebar (box gears first, then system gears
226-
// at the head — Home should sit at the top of navigation when enabled).
227-
orderedIntegrations := make([]auth.SidebarIntegration, 0, len(integrations)+len(systemGears))
228-
for _, i := range systemGears {
229235
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
230236
Name: i.Name,
231237
Enabled: i.Enabled,
232238
SortOrder: i.SortOrder,
233239
})
234240
}
235-
for _, i := range integrations {
236-
orderedIntegrations = append(orderedIntegrations, auth.SidebarIntegration{
237-
Name: i.Name,
238-
Enabled: i.Enabled,
239-
SortOrder: i.SortOrder,
240-
})
241+
} else {
242+
// No box configured — explicitly mark box-scoped gears off so
243+
// the sidebar doesn't fall back to fail-open and clutter
244+
// first-run with disabled items. System gears (Home) are
245+
// already injected above and remain visible if enabled.
246+
for _, n := range []string{"haproxy", "metrics", "logs", "services", "certificates", "traffic", "alerts", "os_updates"} {
247+
if _, present := status[n]; !present {
248+
status[n] = false
249+
}
241250
}
242-
243-
ctx = auth.SetGearStatus(ctx, status)
244-
ctx = auth.SetIntegrationOrder(ctx, orderedIntegrations)
245-
next.ServeHTTP(w, r.WithContext(ctx))
246-
return
247251
}
248252

249-
// No server configured - explicitly disable all gears in navigation
250-
// This provides a clean initial setup experience without gear clutter
251-
status := map[string]bool{
252-
"metrics": false,
253-
"logs": false,
254-
"services": false,
255-
"certificates": false,
256-
"traffic": false,
257-
"alerts": false,
258-
"os_updates": false,
259-
}
260253
ctx = auth.SetGearStatus(ctx, status)
261-
ctx = auth.SetIntegrationOrder(ctx, []auth.SidebarIntegration{})
254+
ctx = auth.SetIntegrationOrder(ctx, orderedIntegrations)
262255
next.ServeHTTP(w, r.WithContext(ctx))
263256
})
264257
}

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+
}

0 commit comments

Comments
 (0)