diff --git a/internal/handler/auth.go b/internal/handler/auth.go index a994ea8..584ae0f 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "log/slog" "net/http" "strings" @@ -31,6 +32,70 @@ func (h *authHandler) PasswordPage(w http.ResponseWriter, r *http.Request) { ui.Render(w, r, pages.AuthPassword("")) } +func (h *authHandler) LoginWithPassword(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(r.FormValue("email")) + password := r.FormValue("password") + + if email == "" || password == "" { + ui.Render(w, r, pages.AuthPassword("Email and password are required")) + return + } + + user, err := h.authService.LoginWithPassword(email, password) + if err != nil { + slog.Warn("password login failed", "error", err, "email", email) + + msg := "An error occurred. Please try again." + if errors.Is(err, service.ErrInvalidCredentials) { + msg = "Invalid email or password" + } else if errors.Is(err, service.ErrNoPassword) { + msg = "This account uses passwordless login. Please use a magic link." + } + + ui.Render(w, r, pages.AuthPassword(msg)) + return + } + + jwtToken, err := h.authService.GenerateJWT(user) + if err != nil { + slog.Error("failed to generate JWT", "error", err, "user_id", user.ID) + ui.Render(w, r, pages.AuthPassword("An error occurred. Please try again.")) + return + } + + h.authService.SetJWTCookie(w, jwtToken, time.Now().Add(7*24*time.Hour)) + + // Check for pending invite + inviteCookie, err := r.Cookie("pending_invite") + if err == nil && inviteCookie.Value != "" { + spaceID, err := h.inviteService.AcceptInvite(inviteCookie.Value, user.ID) + if err != nil { + slog.Error("failed to process pending invite", "error", err, "token", inviteCookie.Value) + } else { + slog.Info("accepted pending invite", "user_id", user.ID, "space_id", spaceID) + http.SetCookie(w, &http.Cookie{ + Name: "pending_invite", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + } + } + + 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 { + http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) +} + func (h *authHandler) Logout(w http.ResponseWriter, r *http.Request) { h.authService.ClearJWTCookie(w) http.Redirect(w, r, "/", http.StatusSeeOther) diff --git a/internal/handler/settings.go b/internal/handler/settings.go new file mode 100644 index 0000000..9b289cf --- /dev/null +++ b/internal/handler/settings.go @@ -0,0 +1,74 @@ +package handler + +import ( + "errors" + "log/slog" + "net/http" + + "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/pages" +) + +type settingsHandler struct { + authService *service.AuthService + userService *service.UserService +} + +func NewSettingsHandler(authService *service.AuthService, userService *service.UserService) *settingsHandler { + return &settingsHandler{ + authService: authService, + userService: userService, + } +} + +func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + + // Re-fetch user from DB since middleware strips PasswordHash + fullUser, err := h.userService.ByID(user.ID) + if err != nil { + slog.Error("failed to fetch user for settings", "error", err, "user_id", user.ID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "")) +} + +func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + // Re-fetch user to check HasPassword + fullUser, err := h.userService.ByID(user.ID) + if err != nil { + slog.Error("failed to fetch user for set password", "error", err, "user_id", user.ID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword) + if err != nil { + slog.Warn("set password failed", "error", err, "user_id", user.ID) + + msg := "An error occurred. Please try again." + if errors.Is(err, service.ErrInvalidCredentials) { + msg = "Current password is incorrect" + } else if errors.Is(err, service.ErrPasswordsDoNotMatch) { + msg = "New passwords do not match" + } else if errors.Is(err, service.ErrWeakPassword) { + msg = "Password must be at least 12 characters" + } + + ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg)) + return + } + + // Password set successfully — render page with success message + ui.Render(w, r, pages.AppSettings(true, "")) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 2497cf3..a671cc6 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -14,6 +14,7 @@ func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler(a.AuthService, a.InviteService) home := handler.NewHomeHandler() dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService) + settings := handler.NewSettingsHandler(a.AuthService, a.UserService) space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.EventBus) mux := http.NewServeMux() @@ -43,7 +44,8 @@ func SetupRoutes(a *app.App) http.Handler { // Auth Actions mux.HandleFunc("POST /auth/magic-link", authRateLimiter(middleware.RequireGuest(auth.SendMagicLink))) - mux.HandleFunc("POST /auth/logout", authRateLimiter(auth.Logout)) + mux.HandleFunc("POST /auth/password", authRateLimiter(middleware.RequireGuest(auth.LoginWithPassword))) + mux.HandleFunc("POST /auth/logout", auth.Logout) // ==================================================================================== // PRIVATE ROUTES @@ -53,6 +55,8 @@ func SetupRoutes(a *app.App) http.Handler { mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding))) mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) + mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage)) + mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword))) // Space routes spaceDashboardHandler := middleware.RequireAuth(space.DashboardPage) diff --git a/internal/service/auth.go b/internal/service/auth.go index 8274e32..01c8b78 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -84,6 +84,11 @@ func (s *AuthService) LoginWithPassword(email, password string) (*model.User, er return nil, e.WithError(ErrNoPassword) } + err = s.ComparePassword(password, *user.PasswordHash) + if err != nil { + return nil, e.WithError(ErrInvalidCredentials) + } + return user, nil } @@ -109,6 +114,45 @@ func (s *AuthService) ComparePassword(password, hash string) error { return nil } +func (s *AuthService) SetPassword(userID, currentPassword, newPassword, confirmPassword string) error { + e := exception.New("AuthService.SetPassword") + + user, err := s.userRepository.ByID(userID) + if err != nil { + return e.WithError(err) + } + + // If user already has a password, verify current password + if user.HasPassword() { + err = s.ComparePassword(currentPassword, *user.PasswordHash) + if err != nil { + return e.WithError(ErrInvalidCredentials) + } + } + + if newPassword != confirmPassword { + return e.WithError(ErrPasswordsDoNotMatch) + } + + err = validation.ValidatePassword(newPassword) + if err != nil { + return e.WithError(ErrWeakPassword) + } + + hashed, err := s.HashPassword(newPassword) + if err != nil { + return e.WithError(err) + } + + user.PasswordHash = &hashed + err = s.userRepository.Update(user) + if err != nil { + return e.WithError(err) + } + + return nil +} + func (s *AuthService) GenerateJWT(user *model.User) (string, error) { claims := jwt.MapClaims{ "user_id": user.ID, diff --git a/internal/ui/layouts/app.templ b/internal/ui/layouts/app.templ index 48f55a4..416c54f 100644 --- a/internal/ui/layouts/app.templ +++ b/internal/ui/layouts/app.templ @@ -146,14 +146,14 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) { } @dropdown.Separator() - - - - - - - - + @dropdown.Item(dropdown.ItemProps{ + Href: "/app/settings", + }) { + + @icon.Settings(icon.Props{Size: 16, Class: "mr-2"}) + Settings + + }