diff --git a/go.mod b/go.mod index e726ab4..a533b9b 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( github.com/Oudwins/tailwind-merge-go v0.2.1 github.com/a-h/templ v0.3.960 github.com/alexedwards/argon2id v1.0.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.6 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.26.0 + github.com/resend/resend-go/v2 v2.28.0 modernc.org/sqlite v1.40.1 ) @@ -52,7 +54,6 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/resend/resend-go/v2 v2.28.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go new file mode 100644 index 0000000..c917fa1 --- /dev/null +++ b/internal/handler/dashboard.go @@ -0,0 +1,14 @@ +package handler + +import "net/http" + +type dashboardHandler struct{} + +func NewDashboardHandler() *dashboardHandler { + return &dashboardHandler{} +} + +func (h *dashboardHandler) DashboardPage(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte("Dashboard page")) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6e18424..83c11cb 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,8 +4,23 @@ import ( "net/http" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/service" ) +// TODO: implement clearing jwt token in auth service + +// AuthMiddleware checks for JWT token and adds user + profile + subscription to context if valid +func AuthMiddleware(authService *service.AuthService, userService *service.UserService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: get auth cookie and verify value + // TODO: fetch user information from database if cookie value is valid + // TODO: add user to context if valid + next.ServeHTTP(w, r) + }) + } +} + // RequireGuest ensures request is not authenticated func RequireGuest(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -22,3 +37,37 @@ func RequireGuest(next http.HandlerFunc) http.HandlerFunc { next.ServeHTTP(w, r) } } + +// RequireAuth ensures the user is authenticated and has completed onboarding +func RequireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + if user == nil { + // For HTMX requests, use HX-Redirect header to force full page redirect + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/auth") + w.WriteHeader(http.StatusSeeOther) + return + } + // For regular requests, use standard redirect + http.Redirect(w, r, "/auth", http.StatusSeeOther) + return + } + + // 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 + } + + next.ServeHTTP(w, r) + } +} diff --git a/internal/middleware/redirect.go b/internal/middleware/redirect.go new file mode 100644 index 0000000..a0aa13e --- /dev/null +++ b/internal/middleware/redirect.go @@ -0,0 +1,14 @@ +package middleware + +import "net/http" + +func Redirect(path string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", path) + w.WriteHeader(http.StatusSeeOther) + return + } + http.Redirect(w, r, path, http.StatusSeeOther) + }) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index be009d2..e38b0d8 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -13,6 +13,7 @@ import ( func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler() home := handler.NewHomeHandler() + dashboard := handler.NewDashboardHandler() mux := http.NewServeMux() @@ -28,6 +29,12 @@ func SetupRoutes(a *app.App) http.Handler { mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage)) mux.HandleFunc("GET /auth/password", middleware.RequireGuest(auth.PasswordPage)) + // ==================================================================================== + // PRIVATE ROUTES + // ==================================================================================== + + mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) + // 404 mux.HandleFunc("/{path...}", home.NotFoundPage) @@ -37,6 +44,7 @@ func SetupRoutes(a *app.App) http.Handler { middleware.Config(a.Cfg), middleware.RequestLogging, middleware.CSRFProtection, + middleware.AuthMiddleware(a.AuthService, a.UserService), middleware.WithURLPath, ) diff --git a/internal/service/auth.go b/internal/service/auth.go index 0ded001..3a73ed7 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -8,6 +8,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" "github.com/alexedwards/argon2id" + "github.com/golang-jwt/jwt/v5" ) var ( @@ -73,3 +74,7 @@ func (s *AuthService) ComparePassword(password, hash string) error { } return nil } + +func (s *AuthService) VerifyJWT(value string) (jwt.MapClaims, error) { + return nil, nil +}