handle onboarding (set name)
This commit is contained in:
parent
94a05b0433
commit
ce3577292e
7 changed files with 265 additions and 64 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -194,55 +194,55 @@ func (s *AuthService) SendMagicLink(email string) error {
|
||||||
Email: email,
|
Email: email,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
_, err := s.userRepository.Create(user)
|
_, err := s.userRepository.Create(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create user: %w", err)
|
return fmt.Errorf("failed to create user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("new user created with id", "id", user.ID)
|
slog.Info("new user created with id", "id", user.ID)
|
||||||
|
|
||||||
profile := &model.Profile{
|
profile := &model.Profile{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Name: "",
|
Name: "",
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.profileRepository.Create(profile)
|
_, err = s.profileRepository.Create(profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create profile: %w", err)
|
return fmt.Errorf("failed to create profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
|
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
|
||||||
} else {
|
} else {
|
||||||
// user look up unexpected error
|
// user look up unexpected error
|
||||||
return fmt.Errorf("failed to look up user: %w", err)
|
return fmt.Errorf("failed to look up user: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.tokenRepository.DeleteByUserAndType(user.ID, model.TokenTypeMagicLink)
|
err = s.tokenRepository.DeleteByUserAndType(user.ID, model.TokenTypeMagicLink)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to delete old magic link tokens", "error", err, "user_id", user.ID)
|
slog.Warn("failed to delete old magic link tokens", "error", err, "user_id", user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
magicToken, err := s.GenerateToken()
|
magicToken, err := s.GenerateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate token: %w", err)
|
return fmt.Errorf("failed to generate token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := &model.Token{
|
token := &model.Token{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Type: model.TokenTypeMagicLink,
|
Type: model.TokenTypeMagicLink,
|
||||||
Token: magicToken,
|
Token: magicToken,
|
||||||
ExpiresAt: time.Now().Add(s.tokenMagicLinkExpiry),
|
ExpiresAt: time.Now().Add(s.tokenMagicLinkExpiry),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.tokenRepository.Create(token)
|
_, err = s.tokenRepository.Create(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create token: %w", err)
|
return fmt.Errorf("failed to create token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := s.profileRepository.ByUserID(user.ID)
|
profile, err := s.profileRepository.ByUserID(user.ID)
|
||||||
name := ""
|
name := ""
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
86
internal/ui/pages/onboarding.templ
Normal file
86
internal/ui/pages/onboarding.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/validation/name.go
Normal file
20
internal/validation/name.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue