diff --git a/internal/infra/http/api/login.go b/internal/infra/http/api/login.go index d37e214..fc60c31 100644 --- a/internal/infra/http/api/login.go +++ b/internal/infra/http/api/login.go @@ -1,26 +1,18 @@ package api import ( - "bytes" "encoding/json" - "io" "net/http" "github.com/julienschmidt/httprouter" - log "github.com/sirupsen/logrus" ) -func (h *Router) login(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - body, err := io.ReadAll(r.Body) - if err != nil { - log.WithError(err).Error("login: failed to read request body") - _ = BadRequest(w, "invalid request body") - return - } - // Restore the body for later use by toLoginCommand - r.Body = io.NopCloser(bytes.NewBuffer(body)) +// isHTMXRequest checks if the request is from HTMX +func isHTMXRequest(r *http.Request) bool { + return r.Header.Get("HX-Request") == "true" +} - log.WithField("body", string(body)).Info("login: received request") +func (h *Router) login(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { command, err := toLoginCommand(w, r, nil) if err != nil { return @@ -28,10 +20,33 @@ func (h *Router) login(w http.ResponseWriter, r *http.Request, _ httprouter.Para response, err := h.authService.Login(command) if err != nil { - _ = BadRequest(w, err.Error()) + if isHTMXRequest(r) { + // Return HTML error for HTMX requests + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`
`)) + } else { + _ = BadRequest(w, err.Error()) + } return } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(response) + if isHTMXRequest(r) { + // For HTMX: Store token in cookie and redirect + http.SetCookie(w, &http.Cookie{ + Name: "authToken", + Value: response.AccessToken, + Path: "/", + HttpOnly: false, // JavaScript needs to read it + Secure: false, + SameSite: http.SameSiteLaxMode, + MaxAge: 3600 * 24 * 7, // 7 days + }) + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + } else { + // JSON API response + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + } } diff --git a/internal/infra/http/api/logout.go b/internal/infra/http/api/logout.go new file mode 100644 index 0000000..206d390 --- /dev/null +++ b/internal/infra/http/api/logout.go @@ -0,0 +1,23 @@ +package api + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" +) + +func (h *Router) logout(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + // For HTMX: Clear cookie and redirect + if isHTMXRequest(r) { + http.SetCookie(w, &http.Cookie{ + Name: "authToken", + Value: "", + Path: "/", + }) + w.Header().Set("HX-Redirect", "/login.html") + w.WriteHeader(http.StatusOK) + } else { + // For JSON API: just return success + w.WriteHeader(http.StatusOK) + } +} diff --git a/internal/infra/http/api/model.go b/internal/infra/http/api/model.go index 10e7b0b..f846838 100644 --- a/internal/infra/http/api/model.go +++ b/internal/infra/http/api/model.go @@ -242,10 +242,25 @@ func (r *LoginRequest) Validate() error { func toLoginCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (*auth.LoginCommand, error) { request := &LoginRequest{} - if err := json.NewDecoder(r.Body).Decode(request); err != nil { - log.WithError(err).Error("login: failed to decode request body") - _ = BadRequest(w, "invalid request body") - return nil, err + + // Check if this is a form submission (HTMX) or JSON request + contentType := r.Header.Get("Content-Type") + if isHTMXRequest(r) || contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data" { + // Parse form data + if err := r.ParseForm(); err != nil { + log.WithError(err).Error("login: failed to parse form data") + _ = BadRequest(w, "invalid request body") + return nil, err + } + request.Email = r.FormValue("email") + request.Password = r.FormValue("password") + } else { + // Parse JSON + if err := json.NewDecoder(r.Body).Decode(request); err != nil { + log.WithError(err).Error("login: failed to decode request body") + _ = BadRequest(w, "invalid request body") + return nil, err + } } if err := request.Validate(); err != nil { @@ -274,10 +289,25 @@ func (r *SignupRequest) Validate() error { func toSignupCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (*auth.SignupCommand, error) { request := &SignupRequest{} - if err := json.NewDecoder(r.Body).Decode(request); err != nil { - log.WithError(err).Error("signup: failed to decode request body") - _ = BadRequest(w, "invalid request body") - return nil, err + + // Check if this is a form submission (HTMX) or JSON request + contentType := r.Header.Get("Content-Type") + if isHTMXRequest(r) || contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data" { + // Parse form data + if err := r.ParseForm(); err != nil { + log.WithError(err).Error("signup: failed to parse form data") + _ = BadRequest(w, "invalid request body") + return nil, err + } + request.Email = r.FormValue("email") + request.Password = r.FormValue("password") + } else { + // Parse JSON + if err := json.NewDecoder(r.Body).Decode(request); err != nil { + log.WithError(err).Error("signup: failed to decode request body") + _ = BadRequest(w, "invalid request body") + return nil, err + } } if err := request.Validate(); err != nil { diff --git a/internal/infra/http/api/router.go b/internal/infra/http/api/router.go index 1b2034d..ece83ff 100644 --- a/internal/infra/http/api/router.go +++ b/internal/infra/http/api/router.go @@ -93,6 +93,7 @@ func New(itemService ItemService, feedService FeedService, authService AuthServi router.POST("/login", chain.Wrap(h.login)) router.POST("/signup", chain.Wrap(h.signup)) + router.POST("/logout", chain.Wrap(h.logout)) // serve static files for GET / router.NotFound = http.FileServer(http.Dir("public")) diff --git a/internal/infra/http/api/signup.go b/internal/infra/http/api/signup.go index eaad286..4eacd79 100644 --- a/internal/infra/http/api/signup.go +++ b/internal/infra/http/api/signup.go @@ -1,33 +1,35 @@ package api import ( - "bytes" - "io" "net/http" "github.com/julienschmidt/httprouter" - log "github.com/sirupsen/logrus" ) func (h *Router) signup(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - body, err := io.ReadAll(r.Body) - if err != nil { - log.WithError(err).Error("signup: failed to read request body") - _ = BadRequest(w, "invalid request body") - return - } - r.Body = io.NopCloser(bytes.NewBuffer(body)) - - log.WithField("body", string(body)).Info("signup: received request") command, err := toSignupCommand(w, r, nil) if err != nil { return } if err := h.authService.Signup(command); err != nil { - _ = BadRequest(w, err.Error()) + if isHTMXRequest(r) { + // Return HTML error for HTMX requests + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(``)) + } else { + _ = BadRequest(w, err.Error()) + } return } - w.WriteHeader(http.StatusCreated) + if isHTMXRequest(r) { + // For HTMX: Redirect to login page + w.Header().Set("HX-Redirect", "/login.html") + w.WriteHeader(http.StatusCreated) + } else { + // JSON API response + w.WriteHeader(http.StatusCreated) + } } diff --git a/package.json b/package.json index ab86edf..fb1eaa7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,6 @@ "author": "", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.48.0" + "@playwright/test": "^1.56.1" } } diff --git a/public/js/auth.js b/public/js/auth.js index 4596181..c4b31e9 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -4,7 +4,22 @@ function setAuthToken(token) { } function getAuthToken() { - return localStorage.getItem('authToken'); + let token = localStorage.getItem('authToken'); + if (!token) { + console.log('Auth token not found in localStorage, checking cookies'); + // read from cookie as fallback + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + let cookie = cookies[i].trim(); + if (cookie.startsWith('authToken=')) { + token = cookie.substring('authToken='.length); + setAuthToken(token); + document.cookie = 'authToken=; path=/; max-age=0'; + return token; + } + } + } + return token; } function clearAuthToken() { diff --git a/public/js/htmx.min.js b/public/js/htmx.min.js new file mode 100644 index 0000000..5b9199d --- /dev/null +++ b/public/js/htmx.min.js @@ -0,0 +1,131 @@ +/* + * htmx.org v1.9.10 + * Minimal working implementation for form submissions + */ +(function(){ + 'use strict'; + + var htmx = { + version: "1.9.10-minimal", + + // Serialize form to URL-encoded string + serializeForm: function(form) { + var formData = new FormData(form); + var pairs = []; + for(var pair of formData.entries()) { + pairs.push(encodeURIComponent(pair[0]) + '=' + encodeURIComponent(pair[1])); + } + return pairs.join('&'); + }, + + processNode: function(elt) { + var target = elt.getAttribute('hx-target'); + var swap = elt.getAttribute('hx-swap') || 'innerHTML'; + var verb = null; + var path = null; + + if (elt.getAttribute('hx-post')) { + verb = 'POST'; + path = elt.getAttribute('hx-post'); + } else if (elt.getAttribute('hx-get')) { + verb = 'GET'; + path = elt.getAttribute('hx-get'); + } + + if (!verb || !path) return; + + var handler = function(evt) { + if (elt.tagName === 'FORM') { + evt.preventDefault(); + } + + var targetElt = target ? document.querySelector(target) : elt; + var data = null; + + if (elt.tagName === 'FORM' && verb === 'POST') { + data = htmx.serializeForm(elt); + } + + var xhr = new XMLHttpRequest(); + xhr.open(verb, path, true); + xhr.setRequestHeader('HX-Request', 'true'); + xhr.setRequestHeader('HX-Current-URL', window.location.href); + + if (data && verb === 'POST') { + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + } + + xhr.onload = function() { + // Check for HX-Redirect header + var redirect = xhr.getResponseHeader('HX-Redirect'); + if (redirect) { + window.location.href = redirect; + return; + } + + // Handle HX-Refresh + var refresh = xhr.getResponseHeader('HX-Refresh'); + if (refresh === 'true') { + window.location.reload(); + return; + } + + if (xhr.status >= 200 && xhr.status < 300) { + if (targetElt && xhr.responseText) { + if (swap === 'innerHTML') { + targetElt.innerHTML = xhr.responseText; + } else if (swap === 'outerHTML') { + targetElt.outerHTML = xhr.responseText; + } else if (swap === 'beforebegin') { + targetElt.insertAdjacentHTML('beforebegin', xhr.responseText); + } else if (swap === 'afterbegin') { + targetElt.insertAdjacentHTML('afterbegin', xhr.responseText); + } else if (swap === 'beforeend') { + targetElt.insertAdjacentHTML('beforeend', xhr.responseText); + } else if (swap === 'afterend') { + targetElt.insertAdjacentHTML('afterend', xhr.responseText); + } + } + } else { + // Error - still swap if there's content + if (targetElt && xhr.responseText) { + targetElt.innerHTML = xhr.responseText; + } + } + }; + + xhr.onerror = function() { + console.error('HTMX request failed'); + }; + + xhr.send(data || null); + }; + + if (elt.tagName === 'FORM') { + elt.addEventListener('submit', handler); + } else { + elt.addEventListener('click', handler); + } + }, + + init: function() { + // Process all elements with hx-* attributes + var elements = document.querySelectorAll('[hx-post], [hx-get]'); + for (var i = 0; i < elements.length; i++) { + this.processNode(elements[i]); + } + } + }; + + // Initialize on DOM ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + htmx.init(); + }); + } else { + htmx.init(); + } + + // Export + window.htmx = htmx; +})(); diff --git a/public/js/login.js b/public/js/login.js index 3558d60..c127f98 100755 --- a/public/js/login.js +++ b/public/js/login.js @@ -1,81 +1,35 @@ -$(document).ready(function () { - // Login form validation and submission handling - var login = { - form: $('.login-form'), - - init: function() { - console.log(this.form); - var success = sessionStorage.getItem('signupSuccess'); - if (success === 'true') { - sessionStorage.removeItem('signupSuccess'); - $('#signup-successful').removeClass('hidden'); - } - this.form.submit(function(e) { - e.preventDefault(); - login.validate(); - }); - }, - - validate: function() { - var email = $('#email').val().trim(); - var password = $('#password').val(); - var isValid = true; - - // Reset previous error states - $('.form-group').removeClass('has-error'); - $('.error-message').remove(); - - // Email validation - if (!email || !this.isValidEmail(email)) { - this.showError($('#email'), 'Please enter a valid email address'); - isValid = false; - } - - // Password validation - if (!password || password.length < 6) { - this.showError($('#password'), 'Password must be at least 6 characters'); - isValid = false; - } - - if (isValid) { - this.submitForm(); - } - }, - - isValidEmail: function(email) { - var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }, - - showError: function(element, message) { - element.parent('.form-group').addClass('has-error'); - $('') - .insertAfter(element); - }, - - submitForm: function() { - var data = { - email: $('#email').val().trim(), - password: $('#password').val() - }; - $.ajax({ - url: '/login', - type: 'POST', - data: JSON.stringify(data), - dataType: 'json', - contentType: 'application/json', - success: function(response) { - setAuthToken(response.access_token); - window.location.href = '/'; - }, - error: function(xhr) { - console.log(xhr); - login.showError($('#email'), 'Invalid email or password'); +// Login page - HTMX handles form submission +// This script only handles: signup success message and auth token storage +document.addEventListener('DOMContentLoaded', function() { + // Show signup success message if redirected from signup + var success = sessionStorage.getItem('signupSuccess'); + if (success === 'true') { + sessionStorage.removeItem('signupSuccess'); + document.getElementById('signup-successful').classList.remove('hidden'); + } + + // Listen for HTMX events to handle auth token + document.body.addEventListener('htmx:beforeSwap', function(event) { + // Check if this is the login form response + if (event.detail.target && event.detail.target.id === 'form-messages') { + var xhr = event.detail.xhr; + if (xhr && xhr.status === 200) { + // Check for auth token in cookie + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim(); + console.log('Checking cookie:', cookie); + if (cookie.startsWith('authToken=')) { + var token = cookie.substring('authToken='.length); + if (token) { + // Store token in localStorage + setAuthToken(token); + // Clear the cookie + document.cookie = 'authToken=; path=/; max-age=0'; + } + } } - }); + } } - }; - - // Initialize login functionality - login.init(); + }); }); diff --git a/public/js/main.js b/public/js/main.js index 0a5c201..89f0e97 100755 --- a/public/js/main.js +++ b/public/js/main.js @@ -16,8 +16,7 @@ $(document).ready(function () { feeds.del(this.id); }); $('.logout').click(function(){ - clearAuthToken(); - window.location = '/'; + logout() }); feeds.init(); @@ -39,6 +38,16 @@ $(document).ready(function () { }); }); +function logout() { + $.ajax({ + url: 'logout', + type: "POST", + complete: function() { + clearAuthToken(); + window.location.href = '/login.html'; + } + }) +} // Update the existing addFeed function function addFeed(url) { var aInput = $("#urlToAdd"); diff --git a/public/js/signup.js b/public/js/signup.js index 915444a..f83e330 100644 --- a/public/js/signup.js +++ b/public/js/signup.js @@ -1,85 +1,36 @@ -$(document).ready(function () { - // Signup form validation and submission handling - var signup = { - form: $('.login-form'), - - init: function() { - this.form.submit(function(e) { - e.preventDefault(); - signup.validate(); - }); - }, - - validate: function() { - // Reset previous errors - $('.form-group').removeClass('has-error'); - $('.error-message').remove(); - - var email = $('#email').val().trim(); - var password = $('#password').val(); - var confirmPassword = $('#confirm-password').val(); - var isValid = true; - - // Validate email - if (!this.isValidEmail(email)) { - this.showError($('#email'), 'Please enter a valid email address'); - isValid = false; - } - - // Validate password - if (!this.isValidPassword(password)) { - this.showError($('#password'), 'Password must be at least 6 characters long'); - isValid = false; - } - - // Validate password confirmation - if (password !== confirmPassword) { - this.showError($('#confirm-password'), 'Passwords do not match'); - isValid = false; - } - - if (isValid) { - this.submitForm(email, password); +// Signup page - HTMX handles form submission +// This script only handles: setting success flag for login page +document.addEventListener('DOMContentLoaded', function() { + // Client-side password confirmation validation + var form = document.querySelector('.login-form'); + var password = document.getElementById('password'); + var confirmPassword = document.getElementById('confirm-password'); + + // Add custom validation for password confirmation + confirmPassword.addEventListener('input', function() { + if (password.value !== confirmPassword.value) { + confirmPassword.setCustomValidity('Passwords do not match'); + } else { + confirmPassword.setCustomValidity(''); + } + }); + + password.addEventListener('input', function() { + if (password.value !== confirmPassword.value) { + confirmPassword.setCustomValidity('Passwords do not match'); + } else { + confirmPassword.setCustomValidity(''); + } + }); + + // Listen for successful signup + document.body.addEventListener('htmx:beforeSwap', function(event) { + if (event.detail.target && event.detail.target.id === 'form-messages') { + var xhr = event.detail.xhr; + if (xhr && xhr.status === 201) { + // Signup successful, set flag for login page + sessionStorage.setItem('signupSuccess', 'true'); } - - return isValid; - }, - - isValidEmail: function(email) { - var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }, - - isValidPassword: function(password) { - return password && password.length >= 6; - }, - - showError: function(element, message) { - element.parent('.form-group').addClass('has-error'); - $('') - .insertAfter(element); - }, - - submitForm: function(email, password) { - $.ajax({ - url: '/signup', - type: 'POST', - data: JSON.stringify({ - email: email, - password: password - }), - contentType: 'application/json', - success: function(response) { - sessionStorage.setItem('signupSuccess', 'true'); - window.location.href = '/login.html'; - - }, - error: function(xhr) { - signup.showError($('#email'), 'Error creating account. Email may already be registered.'); - } - }); } - }; - - signup.init(); -}); \ No newline at end of file + }); +}); diff --git a/public/login.html b/public/login.html index c36c1ef..a4ba2aa 100644 --- a/public/login.html +++ b/public/login.html @@ -11,14 +11,18 @@