budgit/internal/handler/auth.go
2026-01-14 21:11:16 +00:00

151 lines
4.4 KiB
Go

package handler
import (
"log/slog"
"net/http"
"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"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
"git.juancwu.dev/juancwu/budgit/internal/validation"
)
type authHandler struct {
authService *service.AuthService
inviteService *service.InviteService
}
func NewAuthHandler(authService *service.AuthService, inviteService *service.InviteService) *authHandler {
return &authHandler{authService: authService, inviteService: inviteService}
}
func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.Auth(""))
}
func (h *authHandler) PasswordPage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.AuthPassword(""))
}
func (h *authHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.authService.ClearJWTCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *authHandler) SendMagicLink(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
ui.Render(w, r, pages.Auth("Email is required"))
return
}
err := validation.ValidateEmail(email)
if err != nil {
ui.Render(w, r, pages.Auth("Please provide a valid email address"))
return
}
err = h.authService.SendMagicLink(email)
if err != nil {
slog.Warn("magic link send failed", "error", err, "email", email)
}
if r.URL.Query().Get("resend") == "true" {
ui.RenderOOB(w, r, toast.Toast(toast.Props{
Title: "Magic link sent",
Description: "Check your email for a new magic link",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}), "beforeend:#toast-container")
return
}
ui.Render(w, r, pages.MagicLinkSent(email))
}
func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) {
tokenString := r.PathValue("token")
user, err := h.authService.VerifyMagicLink(tokenString)
if err != nil {
slog.Warn("magic link verification failed", "error", err, "token", tokenString)
ui.Render(w, r, pages.Auth("Invalid or expired magic link. Please try again."))
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.Auth("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)
// Don't fail the login, just maybe notify user?
} else {
slog.Info("accepted pending invite", "user_id", user.ID, "space_id", spaceID)
// Clear cookie
http.SetCookie(w, &http.Cookie{
Name: "pending_invite",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
// If we want to redirect to the space immediately, we can.
// But check onboarding first.
}
}
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)
}