From ce3577292eb7033c3d85dd22d88b6289877b73a6 Mon Sep 17 00:00:00 2001 From: juancwu <46619361+juancwu@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:43:22 -0500 Subject: [PATCH] handle onboarding (set name) --- internal/handler/auth.go | 36 +++++++- internal/middleware/auth.go | 22 ++--- internal/repository/profile.go | 24 ++++++ internal/routes/routes.go | 7 +- internal/service/auth.go | 134 ++++++++++++++++++----------- internal/ui/pages/onboarding.templ | 86 ++++++++++++++++++ internal/validation/name.go | 20 +++++ 7 files changed, 265 insertions(+), 64 deletions(-) create mode 100644 internal/ui/pages/onboarding.templ create mode 100644 internal/validation/name.go diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 2d858ea..86734d1 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/ui" "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)) - // 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) 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) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 7c50483..4fe6c0f 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -101,17 +101,17 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc { // Check if user has completed onboarding // Uses profile.Name as indicator (empty = incomplete onboarding) - // profile := ctxkeys.Profile(r.Context()) - // if profile.Name == "" && r.URL.Path != "/auth/onboarding" { - // // User hasn't completed onboarding, redirect to onboarding - // if r.Header.Get("HX-Request") == "true" { - // w.Header().Set("HX-Redirect", "/auth/onboarding") - // w.WriteHeader(http.StatusSeeOther) - // return - // } - // http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) - // return - // } + profile := ctxkeys.Profile(r.Context()) + if profile.Name == "" && r.URL.Path != "/auth/onboarding" { + // User hasn't completed onboarding, redirect to onboarding + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/auth/onboarding") + w.WriteHeader(http.StatusSeeOther) + return + } + http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) + return + } next.ServeHTTP(w, r) } diff --git a/internal/repository/profile.go b/internal/repository/profile.go index 7e66a3a..c37b191 100644 --- a/internal/repository/profile.go +++ b/internal/repository/profile.go @@ -3,6 +3,7 @@ package repository import ( "database/sql" "errors" + "fmt" "time" "git.juancwu.dev/juancwu/budgit/internal/model" @@ -16,6 +17,7 @@ var ( type ProfileRepository interface { Create(profile *model.Profile) (string, error) ByUserID(userID string) (*model.Profile, error) + UpdateName(userID, name string) error } type profileRepository struct { @@ -58,3 +60,25 @@ func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) { 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 +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index aa1f525..25d9e6f 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -26,7 +26,7 @@ func SetupRoutes(a *app.App) http.Handler { mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))) // Auth pages - // authRateLimiter := middleware.RateLimitAuth() + authRateLimiter := middleware.RateLimitAuth() mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage)) 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) // 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 // ==================================================================================== + 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)) // 404 diff --git a/internal/service/auth.go b/internal/service/auth.go index 94bcf04..9bea30d 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -194,56 +194,56 @@ func (s *AuthService) SendMagicLink(email string) error { Email: email, CreatedAt: now, } - _, err := s.userRepository.Create(user) - if err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - - slog.Info("new user created with id", "id", user.ID) - - profile := &model.Profile{ - ID: uuid.NewString(), - UserID: user.ID, - Name: "", - CreatedAt: now, - UpdatedAt: now, - } - - _, err = s.profileRepository.Create(profile) - if err != nil { - return fmt.Errorf("failed to create profile: %w", err) - } - - slog.Info("new passwordless user created", "email", email, "user_id", user.ID) - } else { - // user look up unexpected error - return fmt.Errorf("failed to look up user: %w", err) - } - } - - err = s.tokenRepository.DeleteByUserAndType(user.ID, model.TokenTypeMagicLink) - if err != nil { - slog.Warn("failed to delete old magic link tokens", "error", err, "user_id", user.ID) - } - - magicToken, err := s.GenerateToken() - if err != nil { - return fmt.Errorf("failed to generate token: %w", err) - } - - token := &model.Token{ - ID: uuid.NewString(), - UserID: user.ID, - Type: model.TokenTypeMagicLink, - Token: magicToken, - ExpiresAt: time.Now().Add(s.tokenMagicLinkExpiry), - } - - _, err = s.tokenRepository.Create(token) - if err != nil { - return fmt.Errorf("failed to create token: %w", err) - } - + _, err := s.userRepository.Create(user) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + slog.Info("new user created with id", "id", user.ID) + + profile := &model.Profile{ + ID: uuid.NewString(), + UserID: user.ID, + Name: "", + CreatedAt: now, + UpdatedAt: now, + } + + _, err = s.profileRepository.Create(profile) + if err != nil { + return fmt.Errorf("failed to create profile: %w", err) + } + + slog.Info("new passwordless user created", "email", email, "user_id", user.ID) + } else { + // user look up unexpected error + return fmt.Errorf("failed to look up user: %w", err) + } + } + + err = s.tokenRepository.DeleteByUserAndType(user.ID, model.TokenTypeMagicLink) + if err != nil { + slog.Warn("failed to delete old magic link tokens", "error", err, "user_id", user.ID) + } + + magicToken, err := s.GenerateToken() + if err != nil { + return fmt.Errorf("failed to generate token: %w", err) + } + + token := &model.Token{ + ID: uuid.NewString(), + UserID: user.ID, + Type: model.TokenTypeMagicLink, + Token: magicToken, + ExpiresAt: time.Now().Add(s.tokenMagicLinkExpiry), + } + + _, err = s.tokenRepository.Create(token) + if err != nil { + return fmt.Errorf("failed to create token: %w", err) + } + profile, err := s.profileRepository.ByUserID(user.ID) name := "" if err == nil && profile != nil { @@ -291,3 +291,37 @@ func (s *AuthService) VerifyMagicLink(tokenString string) (*model.User, error) { 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 +} diff --git a/internal/ui/pages/onboarding.templ b/internal/ui/pages/onboarding.templ new file mode 100644 index 0000000..2689c90 --- /dev/null +++ b/internal/ui/pages/onboarding.templ @@ -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), + }) { +
+
+
+
+ @button.Button(button.Props{ + Variant: button.VariantSecondary, + Size: button.SizeLg, + Href: "/", + }) { + @icon.Layers() + { cfg.AppName } + } +
+

Welcome!

+ if user != nil { +

Continue as { user.Email }

+ } else { +

What should we call you?

+ } +
+
+ @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 + } +
+
+ @csrf.Token() + Not you? + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantLink, + Class: "p-0 h-auto text-sm", + }) { + Sign out + } +
+
+
+ } +} diff --git a/internal/validation/name.go b/internal/validation/name.go new file mode 100644 index 0000000..da27742 --- /dev/null +++ b/internal/validation/name.go @@ -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 +}