xtablo-source/go-backend/internal/web/handlers/auth.go
Arthur Belleville 9fe6c897e3
feat(17-02): wire PlanningMainContent with real planning view (GREEN)
- dashboard_components.templ: PlanningMainContent(data PlanningTabData) — overview-section heading (h1), day separators with data-day-separator attribute, event rows, empty state via ui.EmptyState
- handlers/auth.go: GetPlanningPage passes views.NewPlanningTabData()
- dashboard_components_templ.go: regenerated by templ generate
- go test ./... -count=1 exits 0
2026-05-17 10:35:30 +02:00

445 lines
13 KiB
Go

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)
UpdateTablo(ctx context.Context, input UpdateTabloInput) 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
}
projects := views.OverviewProjectsFromTablos(tablos)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
content := views.OverviewMainContent(user.DisplayName, user.Email, projects)
var renderErr error
if isHXRequest(r) {
renderErr = views.DashboardContentSwap("/", tablos, content).Render(r.Context(), w)
} else {
renderErr = views.DashboardPage("/", tablos, 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 func(w http.ResponseWriter, r *http.Request) {
h.renderTasksPage(w, r)
}
}
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(views.NewPlanningTabData())
})
}
func (h *AuthHandler) GetChatPage() http.HandlerFunc {
return h.renderAppPage("/chat", func(user PublicUser) templ.Component {
return views.ChatMainContent(views.NewDiscussionTabData())
})
}
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
}
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
})
if err != nil {
http.Error(w, "failed to load projects", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
content := views.NotFoundContent(user.DisplayName)
var renderErr error
if isHXRequest(r) {
renderErr = views.DashboardContentSwap("", tablos, content).Render(r.Context(), w)
} else {
renderErr = views.DashboardPage("", tablos, content).Render(r.Context(), w)
}
if renderErr != 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
}
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{
OwnerID: user.ID,
})
if err != nil {
http.Error(w, "failed to load projects", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
pageContent := content(user)
var renderErr error
if isHXRequest(r) {
renderErr = views.DashboardContentSwap(activePath, tablos, pageContent).Render(r.Context(), w)
} else {
renderErr = views.DashboardPage(activePath, tablos, pageContent).Render(r.Context(), w)
}
if renderErr != 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"
}