Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions internal/infra/http/api/login.go
Original file line number Diff line number Diff line change
@@ -1,37 +1,52 @@
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
}

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(`<div class="error-message">Invalid email or password</div>`))
} 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)
}
}
23 changes: 23 additions & 0 deletions internal/infra/http/api/logout.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
46 changes: 38 additions & 8 deletions internal/infra/http/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions internal/infra/http/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
30 changes: 16 additions & 14 deletions internal/infra/http/api/signup.go
Original file line number Diff line number Diff line change
@@ -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(`<div class="error-message">Error creating account. Email may already be registered.</div>`))
} 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)
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@playwright/test": "^1.48.0"
"@playwright/test": "^1.56.1"
}
}
17 changes: 16 additions & 1 deletion public/js/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
131 changes: 131 additions & 0 deletions public/js/htmx.min.js
Original file line number Diff line number Diff line change
@@ -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;
})();
Loading
Loading