package handlers import ( "context" "crypto/rand" "encoding/hex" "errors" "net/http" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" "xtablo-backend/internal/web/views" "github.com/a-h/templ" ) const sessionCookieName = "xtablo_session" const sessionLifetime = 30 * 24 * time.Hour var ErrUserNotFound = errors.New("user not found") var ErrUserAlreadyExists = errors.New("user already exists") var ErrSessionNotFound = errors.New("session not found") type AuthRepository interface { CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error) GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error) GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error) CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error GetSessionByToken(ctx context.Context, token string) (Session, error) DeleteSessionByToken(ctx context.Context, token string) error CreateTablo(ctx context.Context, input CreateTabloInput) (TabloRecord, error) ListTablos(ctx context.Context, input ListTablosInput) ([]TabloRecord, error) SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error } type CreateAuthUserInput struct { Email string EncryptedPassword string DisplayName string } type AuthUser struct { ID uuid.UUID Email string EncryptedPassword string CreatedAt time.Time UpdatedAt time.Time } type PublicUser struct { ID uuid.UUID Email string DisplayName string CreatedAt time.Time UpdatedAt time.Time } type Session struct { Token string UserID uuid.UUID CreatedAt time.Time UpdatedAt time.Time ExpiresAt time.Time } type AuthHandler struct { repo AuthRepository } func NewAuthHandler(repo AuthRepository) *AuthHandler { return &AuthHandler{repo: repo} } func (h *AuthHandler) GetHome() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticatedUser(r.Context(), r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } tablos, err := h.repo.ListTablos(context.Background(), ListTablosInput{ OwnerID: user.ID, }) if err != nil { http.Error(w, "failed to load projects", http.StatusInternalServerError) return } showAllProjects := r.URL.Query().Get("show_projects") == "all" projects := views.OverviewProjectsFromTablos(tablos) w.Header().Set("Content-Type", "text/html; charset=utf-8") if isHXRequest(r) && targetsOverviewProjectsSection(r) { if err := views.OverviewProjectsSection(projects, showAllProjects).Render(r.Context(), w); err != nil { http.Error(w, "failed to render overview projects", http.StatusInternalServerError) } return } content := views.OverviewMainContent(user.DisplayName, user.Email, projects, showAllProjects) var renderErr error if isHXRequest(r) { renderErr = views.DashboardContentSwap("/", content).Render(r.Context(), w) } else { renderErr = views.DashboardPage("/", content).Render(r.Context(), w) } if renderErr != nil { http.Error(w, "failed to render app page", http.StatusInternalServerError) } } } func (h *AuthHandler) GetTasksPage() http.HandlerFunc { return h.renderAppPage("/tasks", func(user PublicUser) templ.Component { return views.TasksMainContent() }) } func (h *AuthHandler) GetTablosPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { h.renderTablosPage(w, r) } } func (h *AuthHandler) GetPlanningPage() http.HandlerFunc { return h.renderAppPage("/planning", func(user PublicUser) templ.Component { return views.PlanningMainContent() }) } func (h *AuthHandler) GetChatPage() http.HandlerFunc { return h.renderAppPage("/chat", func(user PublicUser) templ.Component { return views.ChatMainContent() }) } func (h *AuthHandler) GetFilesPage() http.HandlerFunc { return h.renderAppPage("/files", func(user PublicUser) templ.Component { return views.FilesMainContent() }) } func (h *AuthHandler) GetFeedbackPage() http.HandlerFunc { return h.renderAppPage("/feedback", func(user PublicUser) templ.Component { return views.FeedbackMainContent() }) } func (h *AuthHandler) GetNotFound() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticatedUser(r.Context(), r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) content := views.NotFoundContent(user.DisplayName) var err error if isHXRequest(r) { err = views.DashboardContentSwap("", content).Render(r.Context(), w) } else { err = views.DashboardPage("", content).Render(r.Context(), w) } if err != nil { http.Error(w, "failed to render not found page", http.StatusInternalServerError) } } } func (h *AuthHandler) renderAppPage(activePath string, content func(user PublicUser) templ.Component) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := h.authenticatedUser(r.Context(), r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") pageContent := content(user) var err error if isHXRequest(r) { err = views.DashboardContentSwap(activePath, pageContent).Render(r.Context(), w) } else { err = views.DashboardPage(activePath, pageContent).Render(r.Context(), w) } if err != nil { http.Error(w, "failed to render app page", http.StatusInternalServerError) } } } func (h *AuthHandler) authenticatedUser(ctx context.Context, r *http.Request) (PublicUser, bool) { userID, ok := h.currentUserID(ctx, r) if !ok { return PublicUser{}, false } user, err := h.repo.GetPublicUserByID(ctx, userID) if err != nil { return PublicUser{}, false } return user, true } func (h *AuthHandler) PostLogout() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var email string if userID, ok := h.currentUserID(r.Context(), r); ok { if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil { email = user.Email } } if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { _ = h.repo.DeleteSessionByToken(r.Context(), cookie.Value) logStoreMutation("delete_session", email, cookie.Value, 0, 0) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/login", http.StatusSeeOther) } } func (h *AuthHandler) GetLoginPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, ok := h.currentUserID(r.Context(), r); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := views.LoginPage().Render(r.Context(), w); err != nil { http.Error(w, "failed to render login page", http.StatusInternalServerError) } } } func (h *AuthHandler) GetSignupPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if _, ok := h.currentUserID(r.Context(), r); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := views.SignupPage().Render(r.Context(), w); err != nil { http.Error(w, "failed to render signup page", http.StatusInternalServerError) } } } func (h *AuthHandler) PostLogin() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form payload", http.StatusBadRequest) return } email := normalizeEmail(r.FormValue("email")) password := strings.TrimSpace(r.FormValue("password")) w.Header().Set("Content-Type", "text/html; charset=utf-8") switch { case email == "" || password == "": w.WriteHeader(http.StatusUnprocessableEntity) _ = views.AuthStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w) return } authUser, err := h.repo.GetAuthUserByEmail(r.Context(), email) if err != nil || bcrypt.CompareHashAndPassword([]byte(authUser.EncryptedPassword), []byte(password)) != nil { w.WriteHeader(http.StatusUnauthorized) _ = views.AuthStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w) return } h.setSession(r.Context(), w, authUser.ID, authUser.Email) w.Header().Set("HX-Redirect", "/") _ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w) } } func (h *AuthHandler) PostSignup() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form payload", http.StatusBadRequest) return } email := normalizeEmail(r.FormValue("email")) password := strings.TrimSpace(r.FormValue("password")) w.Header().Set("Content-Type", "text/html; charset=utf-8") switch { case email == "" || password == "": w.WriteHeader(http.StatusUnprocessableEntity) _ = views.AuthStatus("error", "Veuillez renseigner votre email et choisir un mot de passe.").Render(r.Context(), w) return } if _, err := h.repo.GetAuthUserByEmail(r.Context(), email); err == nil { w.WriteHeader(http.StatusConflict) _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) return } else if !errors.Is(err, ErrUserNotFound) { http.Error(w, "failed to check existing user", http.StatusInternalServerError) return } passwordHash, err := hashPassword(password) if err != nil { http.Error(w, "failed to hash password", http.StatusInternalServerError) return } displayName := displayNameFromEmail(email) userID, err := h.repo.CreateAuthUser(r.Context(), CreateAuthUserInput{ Email: email, EncryptedPassword: passwordHash, DisplayName: displayName, }) if err != nil { if errors.Is(err, ErrUserAlreadyExists) { w.WriteHeader(http.StatusConflict) _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) return } log.Err(err).Msg("failed to create user") w.WriteHeader(http.StatusInternalServerError) _ = views.AuthStatus("error", "Un problème est survenu.").Render(r.Context(), w) return } h.setSession(r.Context(), w, userID, email) w.Header().Set("HX-Redirect", "/") _ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w) } } func (h *AuthHandler) currentUserID(ctx context.Context, r *http.Request) (uuid.UUID, bool) { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { return uuid.Nil, false } session, err := h.repo.GetSessionByToken(ctx, cookie.Value) if err != nil { return uuid.Nil, false } if session.ExpiresAt.Before(time.Now().UTC()) { _ = h.repo.DeleteSessionByToken(ctx, cookie.Value) return uuid.Nil, false } return session.UserID, true } func (h *AuthHandler) setSession(ctx context.Context, w http.ResponseWriter, userID uuid.UUID, email string) { sessionID := randomToken(32) expiresAt := time.Now().UTC().Add(sessionLifetime) if err := h.repo.CreateSession(ctx, sessionID, userID, expiresAt); err != nil { panic(err) } logStoreMutation("create_session", email, sessionID, 0, 0) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sessionID, Path: "/", Expires: expiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } func displayNameFromEmail(email string) string { email = normalizeEmail(email) if email == "" { return "" } return strings.Split(email, "@")[0] } func hashPassword(password string) (string, error) { passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(passwordHash), nil } func randomToken(size int) string { buf := make([]byte, size) if _, err := rand.Read(buf); err != nil { panic(err) } return hex.EncodeToString(buf) } func logStoreMutation(action string, email string, sessionID string, usersCount int, sessionsCount int) { event := log.Info(). Str("component", "auth_store"). Str("action", action). Int("users_count", usersCount). Int("sessions_count", sessionsCount) if email != "" { event = event.Str("email", email) } if sessionID != "" { event = event.Str("session_id", sessionID) } event.Msg("auth store mutated") } func isHXRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func targetsOverviewProjectsSection(r *http.Request) bool { target := strings.TrimSpace(r.Header.Get("HX-Target")) if target == "" { return false } return target == "overview-projects-section" || strings.Contains(target, "#overview-projects-section") }