handle onboarding (set name)

This commit is contained in:
juancwu 2026-01-04 21:43:22 -05:00
commit ce3577292e
7 changed files with 265 additions and 64 deletions

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
@ -82,8 +83,41 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) {
h.authService.SetJWTCookie(w, jwtToken, time.Now().Add(7*24*time.Hour)) h.authService.SetJWTCookie(w, jwtToken, time.Now().Add(7*24*time.Hour))
// TODO: check for onboarding needsOnboarding, err := h.authService.NeedsOnboarding(user.ID)
if err != nil {
slog.Warn("failed to check onboarding status", "error", err, "user_id", user.ID)
}
if needsOnboarding {
slog.Info("new user needs onboarding", "user_id", user.ID, "email", user.Email)
http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther)
return
}
slog.Info("user logged via magic link", "user_id", user.ID, "email", user.Email) slog.Info("user logged via magic link", "user_id", user.ID, "email", user.Email)
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
} }
func (h *authHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.Onboarding(""))
}
func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
if user == nil {
http.Redirect(w, r, "/auth", http.StatusSeeOther)
return
}
name := strings.TrimSpace(r.FormValue("name"))
err := h.authService.CompleteOnboarding(user.ID, name)
if err != nil {
slog.Error("onboarding failed", "error", err, "user_id", user.ID)
ui.Render(w, r, pages.Onboarding("Please enter your name"))
return
}
slog.Info("onboarding completed", "user_id", user.ID, "name", name)
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
}

View file

@ -101,17 +101,17 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
// Check if user has completed onboarding // Check if user has completed onboarding
// Uses profile.Name as indicator (empty = incomplete onboarding) // Uses profile.Name as indicator (empty = incomplete onboarding)
// profile := ctxkeys.Profile(r.Context()) profile := ctxkeys.Profile(r.Context())
// if profile.Name == "" && r.URL.Path != "/auth/onboarding" { if profile.Name == "" && r.URL.Path != "/auth/onboarding" {
// // User hasn't completed onboarding, redirect to onboarding // User hasn't completed onboarding, redirect to onboarding
// if r.Header.Get("HX-Request") == "true" { if r.Header.Get("HX-Request") == "true" {
// w.Header().Set("HX-Redirect", "/auth/onboarding") w.Header().Set("HX-Redirect", "/auth/onboarding")
// w.WriteHeader(http.StatusSeeOther) w.WriteHeader(http.StatusSeeOther)
// return return
// } }
// http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther)
// return return
// } }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
} }

View file

@ -3,6 +3,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
@ -16,6 +17,7 @@ var (
type ProfileRepository interface { type ProfileRepository interface {
Create(profile *model.Profile) (string, error) Create(profile *model.Profile) (string, error)
ByUserID(userID string) (*model.Profile, error) ByUserID(userID string) (*model.Profile, error)
UpdateName(userID, name string) error
} }
type profileRepository struct { type profileRepository struct {
@ -58,3 +60,25 @@ func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
return &profile, nil return &profile, nil
} }
func (r *profileRepository) UpdateName(userID, name string) error {
result, err := r.db.Exec(`
UPDATE profiles
SET name = $1, updated_at = $2
WHERE user_id = $3
`, name, time.Now(), userID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("no profile found for user_id: %s", userID)
}
return nil
}

View file

@ -26,7 +26,7 @@ func SetupRoutes(a *app.App) http.Handler {
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))) mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
// Auth pages // Auth pages
// authRateLimiter := middleware.RateLimitAuth() authRateLimiter := middleware.RateLimitAuth()
mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage)) mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage))
mux.HandleFunc("GET /auth/password", middleware.RequireGuest(auth.PasswordPage)) mux.HandleFunc("GET /auth/password", middleware.RequireGuest(auth.PasswordPage))
@ -35,12 +35,15 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("GET /auth/magic-link/{token}", auth.VerifyMagicLink) mux.HandleFunc("GET /auth/magic-link/{token}", auth.VerifyMagicLink)
// Auth Actions // Auth Actions
mux.HandleFunc("POST /auth/magic-link", middleware.RequireGuest(auth.SendMagicLink)) mux.HandleFunc("POST /auth/magic-link", authRateLimiter(middleware.RequireGuest(auth.SendMagicLink)))
// ==================================================================================== // ====================================================================================
// PRIVATE ROUTES // PRIVATE ROUTES
// ==================================================================================== // ====================================================================================
mux.HandleFunc("GET /auth/onboarding", middleware.RequireAuth(auth.OnboardingPage))
mux.HandleFunc("POST /auth/onboarding", middleware.RequireAuth(auth.CompleteOnboarding))
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
// 404 // 404

View file

@ -291,3 +291,37 @@ func (s *AuthService) VerifyMagicLink(tokenString string) (*model.User, error) {
return user, nil return user, nil
} }
// NeedsOnboarding checks if user needs to complete onboarding (name not set)
func (s *AuthService) NeedsOnboarding(userID string) (bool, error) {
profile, err := s.profileRepository.ByUserID(userID)
if err != nil {
return false, fmt.Errorf("failed to get profile: %w", err)
}
return profile.Name == "", nil
}
// CompleteOnboarding sets the user's name during onboarding
func (s *AuthService) CompleteOnboarding(userID, name string) error {
name = strings.TrimSpace(name)
err := validation.ValidateName(name)
if err != nil {
return err
}
err = s.profileRepository.UpdateName(userID, name)
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
}
user, err := s.userRepository.ByID(userID)
if err == nil {
// TODO: send welcome email
}
slog.Info("onboarding completed", "user_id", user.ID, "name", name)
return nil
}

View file

@ -0,0 +1,86 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
)
templ Onboarding(errorMsg string) {
{{ cfg := ctxkeys.Config(ctx) }}
{{ user := ctxkeys.User(ctx) }}
@layouts.Auth(layouts.SEOProps{
Title: "Complete Your Profile",
Description: "Tell us your name",
Path: ctxkeys.URLPath(ctx),
}) {
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="mb-8">
@button.Button(button.Props{
Variant: button.VariantSecondary,
Size: button.SizeLg,
Href: "/",
}) {
@icon.Layers()
{ cfg.AppName }
}
</div>
<h2 class="text-3xl font-bold">Welcome!</h2>
if user != nil {
<p class="text-muted-foreground mt-2">Continue as <strong>{ user.Email }</strong></p>
} else {
<p class="text-muted-foreground mt-2">What should we call you?</p>
}
</div>
<form action="/auth/onboarding" method="POST" class="space-y-6">
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "name",
Class: "block mb-2",
}) {
Your Name
}
@input.Input(input.Props{
ID: "name",
Name: "name",
Type: input.TypeText,
Placeholder: "John Doe",
HasError: errorMsg != "",
Attributes: templ.Attributes{"autofocus": ""},
})
if errorMsg != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ errorMsg }
}
}
}
@button.Button(button.Props{
Type: button.TypeSubmit,
FullWidth: true,
}) {
Continue
}
</form>
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {
Sign out
}
</form>
</div>
</div>
}
}

View file

@ -0,0 +1,20 @@
package validation
import (
"errors"
"strings"
)
func ValidateName(name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errors.New("name is required")
}
if len(trimmed) > 100 {
return errors.New("name is too long (max 100 characters)")
}
return nil
}