From c4eb878b0e09ec67807b91805e8a6e932532d13b Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 8 May 2026 16:03:54 +0200 Subject: [PATCH] Split components, add sessions to the DB and make the apercu page --- .zed/settings.json | 11 + go-backend/internal/db/queries.sql | 29 +- go-backend/internal/db/repository.go | 45 + go-backend/internal/db/schema.sql | 11 + go-backend/internal/db/sqlc/models.go | 9 + go-backend/internal/db/sqlc/querier.go | 3 + go-backend/internal/db/sqlc/queries.sql.go | 79 +- go-backend/internal/web/handlers/auth.go | 263 +-- .../web/handlers/in_memory_auth_repository.go | 132 ++ .../{login.templ => auth_components.templ} | 203 +-- .../web/views/auth_components_templ.go | 575 +++++++ .../web/views/dashboard_components.templ | 330 ++++ .../web/views/dashboard_components_templ.go | 1523 +++++++++++++++++ go-backend/internal/web/views/home.go | 195 +++ go-backend/internal/web/views/icons.templ | 102 ++ go-backend/internal/web/views/icons_templ.go | 145 ++ go-backend/internal/web/views/login_templ.go | 342 ---- go-backend/internal/web/views/pages.templ | 70 + go-backend/internal/web/views/pages_templ.go | 143 ++ go-backend/router.go | 7 + go-backend/router_test.go | 202 ++- go-backend/static/styles.css | 946 +++++++++- 22 files changed, 4748 insertions(+), 617 deletions(-) create mode 100644 .zed/settings.json create mode 100644 go-backend/internal/web/handlers/in_memory_auth_repository.go rename go-backend/internal/web/views/{login.templ => auth_components.templ} (52%) create mode 100644 go-backend/internal/web/views/auth_components_templ.go create mode 100644 go-backend/internal/web/views/dashboard_components.templ create mode 100644 go-backend/internal/web/views/dashboard_components_templ.go create mode 100644 go-backend/internal/web/views/home.go create mode 100644 go-backend/internal/web/views/icons.templ create mode 100644 go-backend/internal/web/views/icons_templ.go delete mode 100644 go-backend/internal/web/views/login_templ.go create mode 100644 go-backend/internal/web/views/pages.templ create mode 100644 go-backend/internal/web/views/pages_templ.go diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..a81683f --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,11 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "languages": { + "Go": { + "language_servers": ["gopls"], + }, + }, +} diff --git a/go-backend/internal/db/queries.sql b/go-backend/internal/db/queries.sql index a338690..c272095 100644 --- a/go-backend/internal/db/queries.sql +++ b/go-backend/internal/db/queries.sql @@ -10,7 +10,7 @@ INSERT INTO auth.users ( $1, $2, $3, - jsonb_build_object('display_name', sqlc.arg(display_name)), + jsonb_build_object('display_name', sqlc.arg(display_name)::text), now(), now() ) @@ -27,3 +27,30 @@ SELECT id, email, created_at, updated_at, display_name FROM public.users WHERE id = $1 LIMIT 1; + +-- name: CreateSession :exec +INSERT INTO auth.sessions ( + id, + session_token, + user_id, + created_at, + updated_at, + expires_at +) VALUES ( + $1, + $2, + $3, + now(), + now(), + $4 +); + +-- name: GetSessionByToken :one +SELECT id, session_token, user_id, created_at, updated_at, expires_at +FROM auth.sessions +WHERE session_token = $1 +LIMIT 1; + +-- name: DeleteSessionByToken :execrows +DELETE FROM auth.sessions +WHERE session_token = $1; diff --git a/go-backend/internal/db/repository.go b/go-backend/internal/db/repository.go index f288964..8402262 100644 --- a/go-backend/internal/db/repository.go +++ b/go-backend/internal/db/repository.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" @@ -100,3 +102,46 @@ func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid. UpdatedAt: row.UpdatedAt.Time, }, nil } + +func (r *PostgresAuthRepository) CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error { + err := r.queries.CreateSession(ctx, sqlcdb.CreateSessionParams{ + ID: uuid.New(), + SessionToken: token, + UserID: userID, + ExpiresAt: pgtypeTimestamptz(expiresAt), + }) + return err +} + +func (r *PostgresAuthRepository) GetSessionByToken(ctx context.Context, token string) (handlers.Session, error) { + row, err := r.queries.GetSessionByToken(ctx, token) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return handlers.Session{}, handlers.ErrSessionNotFound + } + return handlers.Session{}, err + } + + return handlers.Session{ + Token: row.SessionToken, + UserID: row.UserID, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + ExpiresAt: row.ExpiresAt.Time, + }, nil +} + +func (r *PostgresAuthRepository) DeleteSessionByToken(ctx context.Context, token string) error { + rows, err := r.queries.DeleteSessionByToken(ctx, token) + if err != nil { + return err + } + if rows == 0 { + return handlers.ErrSessionNotFound + } + return nil +} + +func pgtypeTimestamptz(value time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: value, Valid: true} +} diff --git a/go-backend/internal/db/schema.sql b/go-backend/internal/db/schema.sql index 2be7b1a..af9cbfa 100644 --- a/go-backend/internal/db/schema.sql +++ b/go-backend/internal/db/schema.sql @@ -17,6 +17,17 @@ CREATE TABLE IF NOT EXISTS public.users ( display_name text NOT NULL ); +CREATE TABLE IF NOT EXISTS auth.sessions ( + id uuid PRIMARY KEY, + session_token text NOT NULL UNIQUE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth.sessions(user_id); + CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER diff --git a/go-backend/internal/db/sqlc/models.go b/go-backend/internal/db/sqlc/models.go index ee1ee7d..953ca89 100644 --- a/go-backend/internal/db/sqlc/models.go +++ b/go-backend/internal/db/sqlc/models.go @@ -9,6 +9,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type AuthSession struct { + ID uuid.UUID `db:"id"` + SessionToken string `db:"session_token"` + UserID uuid.UUID `db:"user_id"` + CreatedAt pgtype.Timestamptz `db:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at"` + ExpiresAt pgtype.Timestamptz `db:"expires_at"` +} + type AuthUser struct { ID uuid.UUID `db:"id"` Email string `db:"email"` diff --git a/go-backend/internal/db/sqlc/querier.go b/go-backend/internal/db/sqlc/querier.go index 2a2b436..0eb3521 100644 --- a/go-backend/internal/db/sqlc/querier.go +++ b/go-backend/internal/db/sqlc/querier.go @@ -12,8 +12,11 @@ import ( type Querier interface { CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) + CreateSession(ctx context.Context, arg CreateSessionParams) error + DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error) + GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error) } var _ Querier = (*Queries)(nil) diff --git a/go-backend/internal/db/sqlc/queries.sql.go b/go-backend/internal/db/sqlc/queries.sql.go index c24db1b..c10df4f 100644 --- a/go-backend/internal/db/sqlc/queries.sql.go +++ b/go-backend/internal/db/sqlc/queries.sql.go @@ -24,7 +24,7 @@ INSERT INTO auth.users ( $1, $2, $3, - jsonb_build_object('display_name', $4), + jsonb_build_object('display_name', $4::text), now(), now() ) @@ -32,10 +32,10 @@ RETURNING id ` type CreateAuthUserParams struct { - ID uuid.UUID `db:"id"` - Email string `db:"email"` - EncryptedPassword string `db:"encrypted_password"` - DisplayName interface{} `db:"display_name"` + ID uuid.UUID `db:"id"` + Email string `db:"email"` + EncryptedPassword string `db:"encrypted_password"` + DisplayName string `db:"display_name"` } func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) { @@ -50,6 +50,54 @@ func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) return id, err } +const createSession = `-- name: CreateSession :exec +INSERT INTO auth.sessions ( + id, + session_token, + user_id, + created_at, + updated_at, + expires_at +) VALUES ( + $1, + $2, + $3, + now(), + now(), + $4 +) +` + +type CreateSessionParams struct { + ID uuid.UUID `db:"id"` + SessionToken string `db:"session_token"` + UserID uuid.UUID `db:"user_id"` + ExpiresAt pgtype.Timestamptz `db:"expires_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error { + _, err := q.db.Exec(ctx, createSession, + arg.ID, + arg.SessionToken, + arg.UserID, + arg.ExpiresAt, + ) + return err +} + +const deleteSessionByToken = `-- name: DeleteSessionByToken :execrows +DELETE FROM auth.sessions +WHERE session_token = $1 +` + +func (q *Queries) DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error) { + result, err := q.db.Exec(ctx, deleteSessionByToken, sessionToken) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one SELECT id, email, encrypted_password, created_at, updated_at FROM auth.users @@ -97,3 +145,24 @@ func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, er ) return i, err } + +const getSessionByToken = `-- name: GetSessionByToken :one +SELECT id, session_token, user_id, created_at, updated_at, expires_at +FROM auth.sessions +WHERE session_token = $1 +LIMIT 1 +` + +func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error) { + row := q.db.QueryRow(ctx, getSessionByToken, sessionToken) + var i AuthSession + err := row.Scan( + &i.ID, + &i.SessionToken, + &i.UserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.ExpiresAt, + ) + return i, err +} diff --git a/go-backend/internal/web/handlers/auth.go b/go-backend/internal/web/handlers/auth.go index 6dfa6e0..2b3f79b 100644 --- a/go-backend/internal/web/handlers/auth.go +++ b/go-backend/internal/web/handlers/auth.go @@ -7,7 +7,6 @@ import ( "errors" "net/http" "strings" - "sync" "time" "github.com/google/uuid" @@ -15,17 +14,24 @@ import ( "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 } type CreateAuthUserInput struct { @@ -50,82 +56,134 @@ type PublicUser struct { 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 - sessions *sessionStore -} - -type sessionStore struct { - mu sync.RWMutex - sessions map[string]uuid.UUID -} - -type InMemoryAuthRepository struct { - mu sync.RWMutex - authUsers map[string]AuthUser - publicUsers map[uuid.UUID]PublicUser + repo AuthRepository } func NewAuthHandler(repo AuthRepository) *AuthHandler { - return &AuthHandler{ - repo: repo, - sessions: &sessionStore{sessions: map[string]uuid.UUID{}}, - } -} - -func NewInMemoryAuthRepository() *InMemoryAuthRepository { - repo := &InMemoryAuthRepository{ - authUsers: map[string]AuthUser{}, - publicUsers: map[uuid.UUID]PublicUser{}, - } - - demoHash, err := hashPassword("xtablo-demo") - if err != nil { - panic(err) - } - if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ - Email: "demo@xtablo.com", - EncryptedPassword: demoHash, - DisplayName: "demo", - }); err != nil { - panic(err) - } - - return repo + return &AuthHandler{repo: repo} } func (h *AuthHandler) GetHome() http.HandlerFunc { + return h.renderAppPage("/", func(user PublicUser) templ.Component { + return views.OverviewMainContent(user.DisplayName, user.Email) + }) +} + +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 h.renderAppPage("/tablos", func(user PublicUser) templ.Component { + return views.TablosMainContent() + }) +} + +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) { - userID, ok := h.currentUserID(r) + user, ok := h.authenticatedUser(r.Context(), r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - user, err := h.repo.GetPublicUserByID(r.Context(), userID) + 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") - if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil { - http.Error(w, "failed to render home page", http.StatusInternalServerError) + 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); ok { + 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.sessions.delete(cookie.Value, email) + _ = h.repo.DeleteSessionByToken(r.Context(), cookie.Value) + logStoreMutation("delete_session", email, cookie.Value, 0, 0) } http.SetCookie(w, &http.Cookie{ @@ -142,13 +200,13 @@ func (h *AuthHandler) PostLogout() http.HandlerFunc { func (h *AuthHandler) GetLoginPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if _, ok := h.currentUserID(r); ok { + 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.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil { + if err := views.LoginPage().Render(r.Context(), w); err != nil { http.Error(w, "failed to render login page", http.StatusInternalServerError) } } @@ -156,13 +214,13 @@ func (h *AuthHandler) GetLoginPage() http.HandlerFunc { func (h *AuthHandler) GetSignupPage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if _, ok := h.currentUserID(r); ok { + 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.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil { + if err := views.SignupPage().Render(r.Context(), w); err != nil { http.Error(w, "failed to render signup page", http.StatusInternalServerError) } } @@ -194,7 +252,7 @@ func (h *AuthHandler) PostLogin() http.HandlerFunc { return } - h.setSession(w, authUser.ID, authUser.Email) + h.setSession(r.Context(), w, authUser.ID, authUser.Email) w.Header().Set("HX-Redirect", "/") _ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w) } @@ -246,110 +304,51 @@ func (h *AuthHandler) PostSignup() http.HandlerFunc { _ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w) return } - http.Error(w, "failed to create user", http.StatusInternalServerError) + 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(w, userID, email) + 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(r *http.Request) (uuid.UUID, bool) { +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 } - return h.sessions.get(cookie.Value) + 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(w http.ResponseWriter, userID uuid.UUID, email string) { +func (h *AuthHandler) setSession(ctx context.Context, w http.ResponseWriter, userID uuid.UUID, email string) { sessionID := randomToken(32) - h.sessions.set(sessionID, userID, email) + 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 (s *sessionStore) get(sessionID string) (uuid.UUID, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - userID, ok := s.sessions[sessionID] - return userID, ok -} - -func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) { - s.mu.Lock() - defer s.mu.Unlock() - s.sessions[sessionID] = userID - logStoreMutation("create_session", email, sessionID, 0, len(s.sessions)) -} - -func (s *sessionStore) delete(sessionID string, email string) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.sessions, sessionID) - logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions)) -} - -func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) { - r.mu.Lock() - defer r.mu.Unlock() - - if _, exists := r.authUsers[input.Email]; exists { - return uuid.Nil, ErrUserAlreadyExists - } - - id := uuid.New() - now := time.Now().UTC() - authUser := AuthUser{ - ID: id, - Email: input.Email, - EncryptedPassword: input.EncryptedPassword, - CreatedAt: now, - UpdatedAt: now, - } - publicUser := PublicUser{ - ID: id, - Email: input.Email, - DisplayName: input.DisplayName, - CreatedAt: now, - UpdatedAt: now, - } - - r.authUsers[input.Email] = authUser - r.publicUsers[id] = publicUser - logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0) - return id, nil -} - -func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - user, ok := r.authUsers[email] - if !ok { - return AuthUser{}, ErrUserNotFound - } - return user, nil -} - -func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - user, ok := r.publicUsers[id] - if !ok { - return PublicUser{}, ErrUserNotFound - } - return user, nil -} - func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } @@ -394,3 +393,7 @@ func logStoreMutation(action string, email string, sessionID string, usersCount event.Msg("auth store mutated") } + +func isHXRequest(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} diff --git a/go-backend/internal/web/handlers/in_memory_auth_repository.go b/go-backend/internal/web/handlers/in_memory_auth_repository.go new file mode 100644 index 0000000..c9d1d17 --- /dev/null +++ b/go-backend/internal/web/handlers/in_memory_auth_repository.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" +) + +// InMemoryAuthRepository exists only as test support. +// It must not be used as a production auth/session store. +type InMemoryAuthRepository struct { + mu sync.RWMutex + authUsers map[string]AuthUser + publicUsers map[uuid.UUID]PublicUser + sessions map[string]Session +} + +// NewInMemoryAuthRepository creates a testing-only auth repository. +// Use the Postgres-backed repository in real application flows. +func NewInMemoryAuthRepository() *InMemoryAuthRepository { + repo := &InMemoryAuthRepository{ + authUsers: map[string]AuthUser{}, + publicUsers: map[uuid.UUID]PublicUser{}, + sessions: map[string]Session{}, + } + + demoHash, err := hashPassword("xtablo-demo") + if err != nil { + panic(err) + } + if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{ + Email: "demo@xtablo.com", + EncryptedPassword: demoHash, + DisplayName: "demo", + }); err != nil { + panic(err) + } + + return repo +} + +func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.authUsers[input.Email]; exists { + return uuid.Nil, ErrUserAlreadyExists + } + + id := uuid.New() + now := time.Now().UTC() + authUser := AuthUser{ + ID: id, + Email: input.Email, + EncryptedPassword: input.EncryptedPassword, + CreatedAt: now, + UpdatedAt: now, + } + publicUser := PublicUser{ + ID: id, + Email: input.Email, + DisplayName: input.DisplayName, + CreatedAt: now, + UpdatedAt: now, + } + + r.authUsers[input.Email] = authUser + r.publicUsers[id] = publicUser + logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0) + return id, nil +} + +func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.authUsers[email] + if !ok { + return AuthUser{}, ErrUserNotFound + } + return user, nil +} + +func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.publicUsers[id] + if !ok { + return PublicUser{}, ErrUserNotFound + } + return user, nil +} + +func (r *InMemoryAuthRepository) CreateSession(_ context.Context, token string, userID uuid.UUID, expiresAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now().UTC() + r.sessions[token] = Session{ + Token: token, + UserID: userID, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: expiresAt, + } + return nil +} + +func (r *InMemoryAuthRepository) GetSessionByToken(_ context.Context, token string) (Session, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + session, ok := r.sessions[token] + if !ok { + return Session{}, ErrSessionNotFound + } + return session, nil +} + +func (r *InMemoryAuthRepository) DeleteSessionByToken(_ context.Context, token string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.sessions[token]; !ok { + return ErrSessionNotFound + } + delete(r.sessions, token) + return nil +} diff --git a/go-backend/internal/web/views/login.templ b/go-backend/internal/web/views/auth_components.templ similarity index 52% rename from go-backend/internal/web/views/login.templ rename to go-backend/internal/web/views/auth_components.templ index a879cd0..ec75716 100644 --- a/go-backend/internal/web/views/login.templ +++ b/go-backend/internal/web/views/auth_components.templ @@ -1,146 +1,73 @@ package views -templ AuthPage(content templ.Component) { - - - - - - - - - - - - - XTablo - - - - -
-
-
- -
-
- - -} - templ LoginScreen() { -
-

Se connecter à Xtablo

-
- -
- - @AuthDivider() - @GoogleButton() - -
+ @AuthScreen( + "Se connecter à Xtablo", + "/login-v2", + "Découvrez la nouvelle expérience de connexion", + LoginForm(), + AuthScreenFooter("Pas encore de compte ?", "/signup", "S'inscrire"), + ) } templ SignupScreen() { + @AuthScreen( + "S'inscrire à Xtablo", + "/login", + "Vous avez déjà un compte ?", + SignupForm(), + AuthScreenFooter("Vous avez déjà un compte ?", "/login", "Se connecter"), + ) +} + +templ AuthScreen(title string, helperHref string, helperLabel string, form templ.Component, footer templ.Component) {
-

S'inscrire à Xtablo

+

{ title }

- + @form @AuthDivider() @GoogleButton() - + @footer
} -templ HomePage(displayName string, email string) { - - - - - - XTablo - - - -
-
- -

Bienvenue

-

{ displayName }

-

Session active pour { email }

-
- -
-
-
- - +templ LoginForm() { +
+
+ @AuthField("email", "email", "Email *", "email", "Votre email") + @AuthField("password", "password", "Mot de passe *", "password", "Votre mot de passe") + + +
+} + +templ SignupForm() { +
+
+ @AuthField("signup-email", "email", "Email *", "email", "Votre email") + @AuthField("signup-password", "password", "Mot de passe *", "password", "Choisissez un mot de passe") + +
+} + +templ AuthField(fieldID string, fieldName string, fieldLabel string, inputType string, placeholder string) { +
+ + +
+} + +templ AuthScreenFooter(copy string, href string, label string) { +

+ { copy } + +

} templ AuthStatus(kind string, message string) { @@ -164,7 +91,7 @@ templ GoogleButton() {
- +