diff --git a/internal/infra/http/api/login.go b/internal/infra/http/api/login.go index fc60c31..d37e214 100644 --- a/internal/infra/http/api/login.go +++ b/internal/infra/http/api/login.go @@ -1,18 +1,26 @@ package api import ( + "bytes" "encoding/json" + "io" "net/http" "github.com/julienschmidt/httprouter" + log "github.com/sirupsen/logrus" ) -// isHTMXRequest checks if the request is from HTMX -func isHTMXRequest(r *http.Request) bool { - return r.Header.Get("HX-Request") == "true" -} - 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)) + + log.WithField("body", string(body)).Info("login: received request") command, err := toLoginCommand(w, r, nil) if err != nil { return @@ -20,33 +28,10 @@ func (h *Router) login(w http.ResponseWriter, r *http.Request, _ httprouter.Para response, err := h.authService.Login(command) if err != nil { - 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()) - } + _ = BadRequest(w, err.Error()) return } - 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) - } + 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 deleted file mode 100644 index 206d390..0000000 --- a/internal/infra/http/api/logout.go +++ /dev/null @@ -1,23 +0,0 @@ -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 f846838..10e7b0b 100644 --- a/internal/infra/http/api/model.go +++ b/internal/infra/http/api/model.go @@ -242,25 +242,10 @@ func (r *LoginRequest) Validate() error { func toLoginCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (*auth.LoginCommand, error) { request := &LoginRequest{} - - // 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 := 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 { @@ -289,25 +274,10 @@ func (r *SignupRequest) Validate() error { func toSignupCommand(w http.ResponseWriter, r *http.Request, _ httprouter.Params) (*auth.SignupCommand, error) { request := &SignupRequest{} - - // 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 := 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 ece83ff..1b2034d 100644 --- a/internal/infra/http/api/router.go +++ b/internal/infra/http/api/router.go @@ -93,7 +93,6 @@ 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 4eacd79..eaad286 100644 --- a/internal/infra/http/api/signup.go +++ b/internal/infra/http/api/signup.go @@ -1,35 +1,33 @@ 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 { - 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()) - } + _ = BadRequest(w, err.Error()) return } - 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) - } + w.WriteHeader(http.StatusCreated) } diff --git a/package.json b/package.json index fb1eaa7..ab86edf 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,6 @@ "author": "", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.56.1" + "@playwright/test": "^1.48.0" } } diff --git a/public/js/auth.js b/public/js/auth.js index c4b31e9..4596181 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -4,22 +4,7 @@ function setAuthToken(token) { } function getAuthToken() { - 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; + return localStorage.getItem('authToken'); } function clearAuthToken() { diff --git a/public/js/htmx.min.js b/public/js/htmx.min.js deleted file mode 100644 index 5b9199d..0000000 --- a/public/js/htmx.min.js +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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 c127f98..3558d60 100755 --- a/public/js/login.js +++ b/public/js/login.js @@ -1,35 +1,81 @@ -// 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'; - } - } - } +$(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'); + } + }); } - }); + }; + + // Initialize login functionality + login.init(); }); diff --git a/public/js/main.js b/public/js/main.js index 89f0e97..0a5c201 100755 --- a/public/js/main.js +++ b/public/js/main.js @@ -16,7 +16,8 @@ $(document).ready(function () { feeds.del(this.id); }); $('.logout').click(function(){ - logout() + clearAuthToken(); + window.location = '/'; }); feeds.init(); @@ -38,16 +39,6 @@ $(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 f83e330..915444a 100644 --- a/public/js/signup.js +++ b/public/js/signup.js @@ -1,36 +1,85 @@ -// 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'); +$(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); + } + + 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 a4ba2aa..c36c1ef 100644 --- a/public/login.html +++ b/public/login.html @@ -11,18 +11,14 @@