Skip to content

Commit b553772

Browse files
adnaanclaude
andcommitted
feat(auth): extract utilities to pkg/ and fix flash messages
- Extract password, email, token, and security utilities to lvt/pkg/ for import by generated apps (instead of copying to shared/) - Fix flash messages not showing after failed login by using cookie-based approach that survives WebSocket session creation - Flash messages now clear on page reload (standard framework behavior) - Template uses .flash_error/.flash_success state fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3b920e4 commit b553772

14 files changed

Lines changed: 830 additions & 132 deletions

File tree

commands/auth.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,18 @@ func Auth(args []string) error {
104104
fmt.Println(" - app/auth/auth.tmpl (LiveTemplate UI)")
105105
fmt.Println(" - app/auth/middleware.go (route protection middleware)")
106106
fmt.Println(" - app/auth/auth_e2e_test.go (E2E tests with chromedp)")
107-
fmt.Println(" - shared/password/ (bcrypt utilities)")
108-
fmt.Println(" - shared/email/ (email sender interface)")
109107
fmt.Println(" - database/migrations/ (auth tables migration)")
110108
fmt.Println(" - database/queries.sql (auth SQL queries)")
109+
fmt.Println("\n📦 Dependencies added:")
110+
fmt.Println(" - github.com/livetemplate/lvt/pkg/password (bcrypt utilities)")
111+
fmt.Println(" - github.com/livetemplate/lvt/pkg/email (email sender interface)")
111112
fmt.Println("\n📝 Next steps:")
112113
fmt.Println(" 1. Run migrations:")
113114
fmt.Println(" lvt migration up")
114115
fmt.Println("\n 2. Generate sqlc code:")
115116
fmt.Println(" sqlc generate")
116117
fmt.Println("\n 3. Wire auth routes in main.go (see app/auth/auth.go for examples)")
117-
fmt.Println("\n 4. Configure email sender (see shared/email/email.go)")
118+
fmt.Println("\n 4. Configure email sender (see github.com/livetemplate/lvt/pkg/email)")
118119
fmt.Println("\n 5. Run E2E tests (requires Docker):")
119120
fmt.Println(" go test ./app/auth -run TestAuthE2E -v")
120121
fmt.Println("\n💡 Tip: Check app/auth/auth.go for complete usage examples!")

commands/auth_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,12 @@ func TestAuthCommand_Integration(t *testing.T) {
7373
}
7474

7575
// Verify files were created
76-
// Password auth is enabled by default in v0.5.1+
76+
// Note: shared/password and shared/email are no longer generated
77+
// They are imported from github.com/livetemplate/lvt/pkg/
7778
expectedFiles := []string{
78-
"shared/password/password.go",
79-
"shared/email/email.go",
79+
"app/auth/auth.go",
80+
"app/auth/auth.tmpl",
81+
"app/auth/middleware.go",
8082
"database/queries.sql",
8183
}
8284

internal/generator/auth.go

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -43,66 +43,8 @@ func GenerateAuth(projectRoot string, authConfig *AuthConfig) error {
4343
// Load kit loader
4444
kitLoader := kits.DefaultLoader()
4545

46-
// Create directories
47-
passwordDir := filepath.Join(projectRoot, "shared", "password")
48-
if err := os.MkdirAll(passwordDir, 0755); err != nil {
49-
return fmt.Errorf("failed to create password directory: %w", err)
50-
}
51-
52-
// Generate password.go if password auth enabled
53-
if authConfig.EnablePassword {
54-
templateContent, err := kitLoader.LoadKitTemplate(kitName, "auth/password.go.tmpl")
55-
if err != nil {
56-
return fmt.Errorf("failed to load password template: %w", err)
57-
}
58-
59-
outputPath := filepath.Join(passwordDir, "password.go")
60-
61-
tmpl, err := template.New("password").Parse(string(templateContent))
62-
if err != nil {
63-
return fmt.Errorf("failed to parse password template: %w", err)
64-
}
65-
66-
file, err := os.Create(outputPath)
67-
if err != nil {
68-
return fmt.Errorf("failed to create password.go: %w", err)
69-
}
70-
defer file.Close()
71-
72-
if err := tmpl.Execute(file, authConfig); err != nil {
73-
return fmt.Errorf("failed to execute password template: %w", err)
74-
}
75-
}
76-
77-
// Generate email.go if email features enabled
78-
if authConfig.EnableEmailConfirm || authConfig.EnablePasswordReset {
79-
emailDir := filepath.Join(projectRoot, "shared", "email")
80-
if err := os.MkdirAll(emailDir, 0755); err != nil {
81-
return fmt.Errorf("failed to create email directory: %w", err)
82-
}
83-
84-
templateContent, err := kitLoader.LoadKitTemplate(kitName, "auth/email.go.tmpl")
85-
if err != nil {
86-
return fmt.Errorf("failed to load email template: %w", err)
87-
}
88-
89-
outputPath := filepath.Join(emailDir, "email.go")
90-
91-
tmpl, err := template.New("email").Parse(string(templateContent))
92-
if err != nil {
93-
return fmt.Errorf("failed to parse email template: %w", err)
94-
}
95-
96-
file, err := os.Create(outputPath)
97-
if err != nil {
98-
return fmt.Errorf("failed to create email.go: %w", err)
99-
}
100-
defer file.Close()
101-
102-
if err := tmpl.Execute(file, authConfig); err != nil {
103-
return fmt.Errorf("failed to execute email template: %w", err)
104-
}
105-
}
46+
// Note: password and email utilities are imported from github.com/livetemplate/lvt/pkg
47+
// No need to generate shared/ directory anymore
10648

10749
// Generate migration
10850
migrationsDir := filepath.Join(projectRoot, "database", "migrations")
@@ -335,6 +277,7 @@ func GenerateAuth(projectRoot string, authConfig *AuthConfig) error {
335277
dependencies := []string{
336278
"github.com/google/uuid@latest",
337279
"github.com/chromedp/chromedp@latest", // For E2E tests
280+
"github.com/livetemplate/lvt@latest", // Auth utilities (password, email)
338281
}
339282
if authConfig.EnablePassword {
340283
dependencies = append(dependencies, "golang.org/x/crypto@latest")

internal/generator/auth_test.go

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"testing"
88
)
99

10-
func TestGenerateAuth_PasswordUtilities(t *testing.T) {
10+
func TestGenerateAuth_Handler(t *testing.T) {
1111
// Create temp directory
1212
tmpDir := t.TempDir()
1313

@@ -20,33 +20,39 @@ func TestGenerateAuth_PasswordUtilities(t *testing.T) {
2020
t.Fatalf("GenerateAuth failed: %v", err)
2121
}
2222

23-
// Check password.go exists
24-
passwordPath := filepath.Join(tmpDir, "shared", "password", "password.go")
25-
if _, err := os.Stat(passwordPath); os.IsNotExist(err) {
26-
t.Errorf("password.go not generated at %s", passwordPath)
23+
// Check auth.go exists in app/auth/
24+
authPath := filepath.Join(tmpDir, "app", "auth", "auth.go")
25+
if _, err := os.Stat(authPath); os.IsNotExist(err) {
26+
t.Errorf("auth.go not generated at %s", authPath)
2727
}
2828

29-
// Read and verify content
30-
content, err := os.ReadFile(passwordPath)
29+
// Read and verify content imports from pkg/
30+
content, err := os.ReadFile(authPath)
3131
if err != nil {
32-
t.Fatalf("failed to read password.go: %v", err)
32+
t.Fatalf("failed to read auth.go: %v", err)
3333
}
3434

3535
contentStr := string(content)
36-
requiredFuncs := []string{"Hash", "Verify"}
37-
for _, fn := range requiredFuncs {
38-
if !strings.Contains(contentStr, "func "+fn) {
39-
t.Errorf("password.go missing function: %s", fn)
40-
}
36+
37+
// Verify imports from lvt/pkg/ packages (not shared/)
38+
if !strings.Contains(contentStr, "github.com/livetemplate/lvt/pkg/password") {
39+
t.Error("auth.go should import password from lvt/pkg/password")
40+
}
41+
if !strings.Contains(contentStr, "github.com/livetemplate/lvt/pkg/email") {
42+
t.Error("auth.go should import email from lvt/pkg/email")
4143
}
4244

43-
// Verify imports bcrypt
44-
if !strings.Contains(contentStr, "golang.org/x/crypto/bcrypt") {
45-
t.Error("password.go missing bcrypt import")
45+
// Verify does NOT import from shared/
46+
if strings.Contains(contentStr, "/shared/password") {
47+
t.Error("auth.go should NOT import from shared/password (use lvt/pkg/password instead)")
48+
}
49+
if strings.Contains(contentStr, "/shared/email") {
50+
t.Error("auth.go should NOT import from shared/email (use lvt/pkg/email instead)")
4651
}
4752
}
4853

49-
func TestGenerateAuth_EmailSender(t *testing.T) {
54+
func TestGenerateAuth_NoSharedDirectory(t *testing.T) {
55+
// Verify that shared/ directory is no longer generated
5056
tmpDir := t.TempDir()
5157

5258
err := GenerateAuth(tmpDir, &AuthConfig{
@@ -58,31 +64,10 @@ func TestGenerateAuth_EmailSender(t *testing.T) {
5864
t.Fatalf("GenerateAuth failed: %v", err)
5965
}
6066

61-
emailPath := filepath.Join(tmpDir, "shared", "email", "email.go")
62-
if _, err := os.Stat(emailPath); os.IsNotExist(err) {
63-
t.Errorf("email.go not generated at %s", emailPath)
64-
}
65-
66-
content, err := os.ReadFile(emailPath)
67-
if err != nil {
68-
t.Fatalf("failed to read email.go: %v", err)
69-
}
70-
71-
contentStr := string(content)
72-
73-
// Check for EmailSender interface
74-
if !strings.Contains(contentStr, "type EmailSender interface") {
75-
t.Error("email.go missing EmailSender interface")
76-
}
77-
78-
// Check for console logger implementation
79-
if !strings.Contains(contentStr, "type ConsoleEmailSender struct") {
80-
t.Error("email.go missing ConsoleEmailSender")
81-
}
82-
83-
// Check for Send method
84-
if !strings.Contains(contentStr, "func (s *ConsoleEmailSender) Send") {
85-
t.Error("email.go missing Send method")
67+
// Verify shared/ directory does NOT exist
68+
sharedPath := filepath.Join(tmpDir, "shared")
69+
if _, err := os.Stat(sharedPath); err == nil {
70+
t.Error("shared/ directory should NOT be generated (utilities are now in lvt/pkg/)")
8671
}
8772
}
8873

internal/kits/system/multi/templates/auth/handler.go.tmpl

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,14 @@ import (
3939
"fmt"
4040
"log"
4141
"net/http"
42+
"net/url"
4243
"os"
4344
"time"
4445

4546
"{{.ModuleName}}/database/models"
46-
"{{.ModuleName}}/shared/email"
47+
"github.com/livetemplate/lvt/pkg/email"
4748
{{- if .EnablePassword }}
48-
"{{.ModuleName}}/shared/password"
49+
"github.com/livetemplate/lvt/pkg/password"
4950
{{- end }}
5051
"github.com/go-playground/validator/v10"
5152
"github.com/google/uuid"
@@ -69,6 +70,8 @@ type {{.StructName}}State struct {
6970
Token string `json:"token"`
7071
ShowMagicLink bool `json:"show_magic_link"`
7172
ShowPassword bool `json:"show_password"`
73+
FlashError string `json:"flash_error"`
74+
FlashSuccess string `json:"flash_success"`
7275
}
7376

7477
func New{{.StructName}}Controller(queries *models.Queries, emailSender email.EmailSender, baseURL string) *{{.StructName}}Controller {
@@ -105,24 +108,8 @@ var successMessages = map[string]string{
105108
}
106109

107110
// Mount is called once per session to initialize state.
108-
// It reads query params from redirects (e.g., ?error=invalid_credentials) and sets flash messages.
111+
// Flash messages are handled via cookies in the HTTP handler for proper one-time display.
109112
func (c *{{.StructName}}Controller) Mount(state {{.StructName}}State, ctx *livetemplate.Context) ({{.StructName}}State, error) {
110-
// Check for error code from redirect
111-
if errCode := ctx.GetString("error"); errCode != "" {
112-
if msg, ok := errorMessages[errCode]; ok {
113-
ctx.SetFlash("error", msg)
114-
} else {
115-
ctx.SetFlash("error", "An error occurred")
116-
}
117-
}
118-
119-
// Check for success code from redirect
120-
if successCode := ctx.GetString("success"); successCode != "" {
121-
if msg, ok := successMessages[successCode]; ok {
122-
ctx.SetFlash("success", msg)
123-
}
124-
}
125-
126113
return state, nil
127114
}
128115

@@ -766,16 +753,123 @@ func Handler(queries *models.Queries) http.Handler {
766753
}
767754

768755
// Return handler that clones template per-request
769-
// Query params (?error=xxx, ?success=xxx) are handled in Mount via ctx.GetString()
770756
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
757+
// Handle error/success query params by setting flash cookies and redirecting to clean URL
758+
errCode := r.URL.Query().Get("error")
759+
successCode := r.URL.Query().Get("success")
760+
761+
if errCode != "" || successCode != "" {
762+
// Clear livetemplate-id to force new session on next request
763+
// This ensures the flash message will be properly displayed
764+
http.SetCookie(w, &http.Cookie{
765+
Name: "livetemplate-id",
766+
Value: "",
767+
Path: "/",
768+
MaxAge: -1,
769+
})
770+
771+
// Set flash message in cookie (URL-encoded to avoid quoting issues)
772+
if errCode != "" {
773+
if msg, ok := errorMessages[errCode]; ok {
774+
http.SetCookie(w, &http.Cookie{
775+
Name: "flash_error",
776+
Value: url.QueryEscape(msg),
777+
Path: "/",
778+
MaxAge: 10, // Short-lived
779+
HttpOnly: true,
780+
})
781+
}
782+
}
783+
if successCode != "" {
784+
if msg, ok := successMessages[successCode]; ok {
785+
http.SetCookie(w, &http.Cookie{
786+
Name: "flash_success",
787+
Value: url.QueryEscape(msg),
788+
Path: "/",
789+
MaxAge: 10,
790+
HttpOnly: true,
791+
})
792+
}
793+
}
794+
795+
// Set marker so we know flash was triggered (for one-time display)
796+
http.SetCookie(w, &http.Cookie{
797+
Name: "flash_pending",
798+
Value: "1",
799+
Path: "/",
800+
MaxAge: 10,
801+
HttpOnly: true,
802+
})
803+
804+
// Redirect to clean URL
805+
http.Redirect(w, r, "/auth", http.StatusSeeOther)
806+
return
807+
}
808+
771809
tmpl, err := baseTmpl.Clone()
772810
if err != nil {
773811
log.Printf("Failed to clone auth template: %v", err)
774812
http.Error(w, "Internal server error", http.StatusInternalServerError)
775813
return
776814
}
777815

816+
// Only process flash logic for GET requests (not HEAD/WebSocket preflight)
817+
if r.Method == "GET" {
818+
// Check if this is a fresh flash display or a reload
819+
_, errFlashPending := r.Cookie("flash_pending")
820+
_, errSession := r.Cookie("livetemplate-id")
821+
822+
hasFlashPending := errFlashPending == nil // true if cookie exists
823+
hasSession := errSession == nil // true if cookie exists
824+
825+
// If we have a session but no flash_pending marker, this is a reload
826+
// Force new session to clear any stale flash from previous session
827+
if hasSession && !hasFlashPending {
828+
http.SetCookie(w, &http.Cookie{
829+
Name: "livetemplate-id",
830+
Value: "",
831+
Path: "/",
832+
MaxAge: -1,
833+
})
834+
}
835+
836+
// Clear the flash_pending marker after first use
837+
if hasFlashPending {
838+
http.SetCookie(w, &http.Cookie{
839+
Name: "flash_pending",
840+
Value: "",
841+
Path: "/",
842+
MaxAge: -1,
843+
})
844+
}
845+
}
846+
847+
// Read and clear flash cookies, set in initial state
778848
state := New{{.StructName}}State()
849+
850+
if cookie, err := r.Cookie("flash_error"); err == nil && cookie.Value != "" {
851+
if msg, err := url.QueryUnescape(cookie.Value); err == nil {
852+
state.FlashError = msg
853+
}
854+
http.SetCookie(w, &http.Cookie{
855+
Name: "flash_error",
856+
Value: "",
857+
Path: "/",
858+
MaxAge: -1,
859+
})
860+
}
861+
if cookie, err := r.Cookie("flash_success"); err == nil && cookie.Value != "" {
862+
if msg, err := url.QueryUnescape(cookie.Value); err == nil {
863+
state.FlashSuccess = msg
864+
}
865+
http.SetCookie(w, &http.Cookie{
866+
Name: "flash_success",
867+
Value: "",
868+
Path: "/",
869+
MaxAge: -1,
870+
})
871+
}
872+
779873
tmpl.Handle(controller, livetemplate.AsState(state)).ServeHTTP(w, r)
780874
})
781875
}

0 commit comments

Comments
 (0)