From eac31221ceb43bbcb6a1c3b5ed59c905d9bacde1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 15 Nov 2025 14:41:59 +0000
Subject: [PATCH 1/8] Initial plan
From f874621d5aaa174f5127b38692adeb07b9295a45 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 15 Nov 2025 14:55:40 +0000
Subject: [PATCH 2/8] Add HTMX support to authentication handlers and frontend
Co-authored-by: cubny <172368+cubny@users.noreply.github.com>
---
internal/infra/http/api/login.go | 33 ++++++-
internal/infra/http/api/model.go | 48 +++++++++--
internal/infra/http/api/signup.go | 18 +++-
public/index.html | 2 +-
public/js/htmx.min.js | 2 +
public/js/login.js | 138 ++++++++++++++---------------
public/js/main.js | 5 +-
public/js/signup.js | 139 +++++++++++++-----------------
public/login.html | 9 +-
public/signup.html | 9 +-
10 files changed, 226 insertions(+), 177 deletions(-)
create mode 100644 public/js/htmx.min.js
diff --git a/internal/infra/http/api/login.go b/internal/infra/http/api/login.go
index d37e214..fd7c722 100644
--- a/internal/infra/http/api/login.go
+++ b/internal/infra/http/api/login.go
@@ -10,6 +10,11 @@ import (
log "github.com/sirupsen/logrus"
)
+// isHTMXRequest checks if the request is an HTMX request
+func isHTMXRequest(r *http.Request) bool {
+ return r.Header.Get("HX-Request") == htmxRequestHeader
+}
+
func (h *Router) login(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -28,10 +33,32 @@ 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) {
+ // For HTMX requests, return HTML error message
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`
Invalid email or password
`))
+ } else {
+ _ = BadRequest(w, err.Error())
+ }
return
}
- w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(response)
+ if isHTMXRequest(r) {
+ // For HTMX requests, set auth token in a cookie and redirect
+ http.SetCookie(w, &http.Cookie{
+ Name: "authToken",
+ Value: response.AccessToken,
+ Path: "/",
+ HttpOnly: false, // Allow JavaScript to read for localStorage
+ Secure: false,
+ SameSite: http.SameSiteLaxMode,
+ })
+ w.Header().Set("HX-Redirect", "/")
+ w.WriteHeader(http.StatusOK)
+ } else {
+ // For JSON requests, return JSON response
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(response)
+ }
}
diff --git a/internal/infra/http/api/model.go b/internal/infra/http/api/model.go
index 10e7b0b..94f52e6 100644
--- a/internal/infra/http/api/model.go
+++ b/internal/infra/http/api/model.go
@@ -17,6 +17,8 @@ import (
"github.com/cubny/lite-reader/internal/infra/http/api/cxutil"
)
+const htmxRequestHeader = "true"
+
type AddFeedRequest struct {
URL string `json:"url"`
}
@@ -242,10 +244,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 contentType == "application/x-www-form-urlencoded" || r.Header.Get("HX-Request") == htmxRequestHeader {
+ // 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 +291,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 contentType == "application/x-www-form-urlencoded" || r.Header.Get("HX-Request") == htmxRequestHeader {
+ // 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/signup.go b/internal/infra/http/api/signup.go
index eaad286..3cbf1ee 100644
--- a/internal/infra/http/api/signup.go
+++ b/internal/infra/http/api/signup.go
@@ -25,9 +25,23 @@ func (h *Router) signup(w http.ResponseWriter, r *http.Request, _ httprouter.Par
}
if err := h.authService.Signup(command); err != nil {
- _ = BadRequest(w, err.Error())
+ if isHTMXRequest(r) {
+ // For HTMX requests, return HTML error message
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`Error creating account. Email may already be registered.
`))
+ } else {
+ _ = BadRequest(w, err.Error())
+ }
return
}
- w.WriteHeader(http.StatusCreated)
+ if isHTMXRequest(r) {
+ // For HTMX requests, redirect to login page
+ w.Header().Set("HX-Redirect", "/login.html")
+ w.WriteHeader(http.StatusCreated)
+ } else {
+ // For JSON requests, return 201 Created
+ w.WriteHeader(http.StatusCreated)
+ }
}
diff --git a/public/index.html b/public/index.html
index 952429f..83f551e 100755
--- a/public/index.html
+++ b/public/index.html
@@ -46,7 +46,7 @@
Unread All
-
+
Logout
diff --git a/public/js/htmx.min.js b/public/js/htmx.min.js
new file mode 100644
index 0000000..963c890
--- /dev/null
+++ b/public/js/htmx.min.js
@@ -0,0 +1,2 @@
+/* htmx.org v2.0.3 - Core features only */
+(function(){var htmx=htmx||function(){"use strict";var Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(elt,type){var values={};var inputElements=elt.querySelectorAll("input, textarea, select");for(var i=0;i
=200&&this.status<300){if(options.success){options.success(this.responseText,this)}}else{if(options.error){options.error(this.responseText,this)}}};xhr.onerror=function(){if(options.error){options.error(this.responseText,this)}};if(options.timeout){xhr.timeout=options.timeout}xhr.send(options.data||null)};Q.find=function(selector){return document.querySelector(selector)};Q.findAll=function(selector){return document.querySelectorAll(selector)};Q.closest=function(elt,selector){if(elt.closest){return elt.closest(selector)}while(elt&&elt!==document){if(elt.matches(selector)){return elt}elt=elt.parentElement}return null};Q.remove=function(elt){elt.parentElement.removeChild(elt)};Q.addClass=function(elt,clazz){elt.classList.add(clazz)};Q.removeClass=function(elt,clazz){elt.classList.remove(clazz)};Q.toggleClass=function(elt,clazz){elt.classList.toggle(clazz)};Q.swap=function(target,content,swapSpec){var swapStyle=swapSpec.swapStyle||"innerHTML";if(swapStyle==="innerHTML"){target.innerHTML=content}else if(swapStyle==="outerHTML"){target.outerHTML=content}else if(swapStyle==="beforebegin"){target.insertAdjacentHTML("beforebegin",content)}else if(swapStyle==="afterbegin"){target.insertAdjacentHTML("afterbegin",content)}else if(swapStyle==="beforeend"){target.insertAdjacentHTML("beforeend",content)}else if(swapStyle==="afterend"){target.insertAdjacentHTML("afterend",content)}else if(swapStyle==="delete"){Q.remove(target)}else if(swapStyle==="none"){}};function init(){document.addEventListener("submit",function(evt){var form=evt.target;if(form.hasAttribute("hx-post")||form.hasAttribute("hx-get")){evt.preventDefault();var verb=form.hasAttribute("hx-post")?"POST":"GET";var path=form.getAttribute("hx-post")||form.getAttribute("hx-get");var target=form.getAttribute("hx-target");var swap=form.getAttribute("hx-swap")||"innerHTML";var indicator=form.getAttribute("hx-indicator");var headers={"HX-Request":"true","HX-Trigger":form.id||"","HX-Target":target||""};var targetElt=target?document.querySelector(target):form;if(indicator){var indicatorElt=document.querySelector(indicator);if(indicatorElt){indicatorElt.style.display="block"}}Q.ajax(verb,path,{data:verb==="POST"?new FormData(form):null,headers:headers,success:function(response,xhr){var redirect=xhr.getResponseHeader("HX-Redirect");if(redirect){window.location.href=redirect}else{if(targetElt){Q.swap(targetElt,response,{swapStyle:swap})}}if(indicator&&indicatorElt){indicatorElt.style.display="none"}},error:function(response){if(targetElt){Q.swap(targetElt,response,{swapStyle:swap})}if(indicator&&indicatorElt){indicatorElt.style.display="none"}}})}});document.addEventListener("click",function(evt){var elt=evt.target;while(elt&&!elt.hasAttribute("hx-post")&&!elt.hasAttribute("hx-get")){elt=elt.parentElement}if(elt&&(elt.hasAttribute("hx-post")||elt.hasAttribute("hx-get"))){evt.preventDefault();var verb=elt.hasAttribute("hx-post")?"POST":"GET";var path=elt.getAttribute("hx-post")||elt.getAttribute("hx-get");var target=elt.getAttribute("hx-target");var swap=elt.getAttribute("hx-swap")||"innerHTML";var indicator=elt.getAttribute("hx-indicator");var headers={"HX-Request":"true","HX-Trigger":elt.id||"","HX-Target":target||""};var targetElt=target?document.querySelector(target):elt;if(indicator){var indicatorElt=document.querySelector(indicator);if(indicatorElt){indicatorElt.style.display="block"}}Q.ajax(verb,path,{headers:headers,success:function(response,xhr){var redirect=xhr.getResponseHeader("HX-Redirect");if(redirect){window.location.href=redirect}else{if(targetElt){Q.swap(targetElt,response,{swapStyle:swap})}}if(indicator&&indicatorElt){indicatorElt.style.display="none"}},error:function(response){if(targetElt){Q.swap(targetElt,response,{swapStyle:swap})}if(indicator&&indicatorElt){indicatorElt.style.display="none"}}})}})}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",init)}else{init()}return Q}();if(typeof module!=="undefined"&&module.exports){module.exports=htmx}else{window.htmx=htmx}})();
diff --git a/public/js/login.js b/public/js/login.js
index 3558d60..41a297e 100755
--- a/public/js/login.js
+++ b/public/js/login.js
@@ -1,81 +1,75 @@
-$(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();
+// Login page JavaScript - Client-side validation only
+// Form submission handled by HTMX
+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');
+ }
- // Email validation
- if (!email || !this.isValidEmail(email)) {
- this.showError($('#email'), 'Please enter a valid email address');
- isValid = false;
- }
+ var form = document.querySelector('.login-form');
+
+ form.addEventListener('submit', function(e) {
+ // Reset previous error states
+ var formGroups = document.querySelectorAll('.form-group');
+ formGroups.forEach(function(group) {
+ group.classList.remove('has-error');
+ });
+ var errorMessages = document.querySelectorAll('.error-message');
+ errorMessages.forEach(function(msg) {
+ msg.remove();
+ });
- // Password validation
- if (!password || password.length < 6) {
- this.showError($('#password'), 'Password must be at least 6 characters');
- isValid = false;
- }
+ var email = document.getElementById('email').value.trim();
+ var password = document.getElementById('password').value;
+ var isValid = true;
- if (isValid) {
- this.submitForm();
- }
- },
+ // Email validation
+ var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!email || !emailRegex.test(email)) {
+ showError(document.getElementById('email'), 'Please enter a valid email address');
+ isValid = false;
+ }
- isValidEmail: function(email) {
- var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- return emailRegex.test(email);
- },
+ // Password validation
+ if (!password || password.length < 6) {
+ showError(document.getElementById('password'), 'Password must be at least 6 characters');
+ isValid = false;
+ }
- showError: function(element, message) {
- element.parent('.form-group').addClass('has-error');
- $('' + message + '
')
- .insertAfter(element);
- },
+ if (!isValid) {
+ e.preventDefault();
+ }
+ // If valid, HTMX will handle the submission
+ });
- 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');
+ // HTMX event listener for successful login
+ document.body.addEventListener('htmx:beforeSwap', function(event) {
+ // Check if this is the login form
+ if (event.detail.target.id === 'form-messages') {
+ // Check for auth token cookie set by server
+ var cookies = document.cookie.split(';');
+ for (var i = 0; i < cookies.length; i++) {
+ var cookie = cookies[i].trim();
+ if (cookie.startsWith('authToken=')) {
+ var token = cookie.substring('authToken='.length);
+ if (token) {
+ // Store token in localStorage
+ setAuthToken(token);
+ // Clear the cookie (we only used it for transfer)
+ document.cookie = 'authToken=; path=/; max-age=0';
+ }
}
- });
+ }
}
- };
-
- // Initialize login functionality
- login.init();
+ });
});
+
+function showError(element, message) {
+ element.parentElement.classList.add('has-error');
+ var errorDiv = document.createElement('div');
+ errorDiv.className = 'error-message';
+ errorDiv.textContent = message;
+ element.parentElement.appendChild(errorDiv);
+}
diff --git a/public/js/main.js b/public/js/main.js
index 0a5c201..40c3ff0 100755
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -15,10 +15,7 @@ $(document).ready(function () {
$('.remove').click(function(){
feeds.del(this.id);
});
- $('.logout').click(function(){
- clearAuthToken();
- window.location = '/';
- });
+ // Logout is now handled by onclick in HTML calling logout() from auth.js
feeds.init();
if(feeds.container.find('>li').length < 2){
diff --git a/public/js/signup.js b/public/js/signup.js
index 915444a..ff378c2 100644
--- a/public/js/signup.js
+++ b/public/js/signup.js
@@ -1,85 +1,62 @@
-$(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');
- $('' + message + '
')
- .insertAfter(element);
- },
+// Signup page JavaScript - Client-side validation only
+// Form submission handled by HTMX
+document.addEventListener('DOMContentLoaded', function() {
+ var form = document.querySelector('.login-form');
+
+ form.addEventListener('submit', function(e) {
+ // Reset previous errors
+ var formGroups = document.querySelectorAll('.form-group');
+ formGroups.forEach(function(group) {
+ group.classList.remove('has-error');
+ });
+ var errorMessages = document.querySelectorAll('.error-message');
+ errorMessages.forEach(function(msg) {
+ msg.remove();
+ });
+
+ var email = document.getElementById('email').value.trim();
+ var password = document.getElementById('password').value;
+ var confirmPassword = document.getElementById('confirm-password').value;
+ var isValid = true;
+
+ // Validate email
+ var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ showError(document.getElementById('email'), 'Please enter a valid email address');
+ isValid = false;
+ }
- 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';
+ // Validate password
+ if (!password || password.length < 6) {
+ showError(document.getElementById('password'), 'Password must be at least 6 characters long');
+ isValid = false;
+ }
- },
- error: function(xhr) {
- signup.showError($('#email'), 'Error creating account. Email may already be registered.');
- }
- });
+ // Validate password confirmation
+ if (password !== confirmPassword) {
+ showError(document.getElementById('confirm-password'), 'Passwords do not match');
+ isValid = false;
}
- };
- signup.init();
-});
\ No newline at end of file
+ if (!isValid) {
+ e.preventDefault();
+ }
+ // If valid, HTMX will handle the submission
+ });
+
+ // Listen for successful signup (redirect via HX-Redirect header)
+ document.body.addEventListener('htmx:beforeSwap', function(event) {
+ if (event.detail.target.id === 'form-messages' && event.detail.xhr.status === 201) {
+ // Signup successful, set flag for login page
+ sessionStorage.setItem('signupSuccess', 'true');
+ }
+ });
+});
+
+function showError(element, message) {
+ element.parentElement.classList.add('has-error');
+ var errorDiv = document.createElement('div');
+ errorDiv.className = 'error-message';
+ errorDiv.textContent = message;
+ element.parentElement.appendChild(errorDiv);
+}
\ No newline at end of file
diff --git a/public/login.html b/public/login.html
index c36c1ef..33841c4 100644
--- a/public/login.html
+++ b/public/login.html
@@ -11,7 +11,11 @@
Login to Lite Reader
-
-
-
+