Add Homepage, login, signup page UI. Add logout functionality

This commit is contained in:
Leo 2026-08-17 15:42:27 +03:00
parent e1b63d5deb
commit 1d70b3a6d3
13 changed files with 325 additions and 210 deletions

View file

@ -37,9 +37,16 @@ func main() {
mux := http.NewServeMux()
// Routes
mux.HandleFunc("/login", handler.LoginHandler)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static"))))
mux.HandleFunc("GET /{$}", handler.HomeHandler)
mux.HandleFunc("GET /login", handler.LoginPageHandler)
mux.HandleFunc("/user", handler.UserHandler)
mux.HandleFunc("/signup", handler.SignUpHandler)
mux.HandleFunc("/signup", handler.SignUpPageHandler)
mux.HandleFunc("POST /api/login", handler.LoginHandler)
mux.HandleFunc("POST /api/logout", handler.LogoutHandler)
mux.HandleFunc("POST /api/signup", handler.SignUpHandler)
// Apply middleware (CORS first, then logging)
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))

View file

@ -37,20 +37,3 @@ func HashSecret(secret string) []byte {
func CheckExpiration(expirarion time.Time) bool {
return time.Since(expirarion).Seconds() >= sessionExpiresInSeconds
}
func ParseCookies(cookieHeader string) map[string]string {
cookies := make(map[string]string)
if cookieHeader == "" {
return cookies
}
pairs := strings.Split(cookieHeader, ";")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
parts := strings.SplitN(pair, "=", 2)
if len(parts) == 2 {
cookies[parts[0]] = parts[1]
}
}
return cookies
}

View file

@ -4,6 +4,9 @@ import (
"context"
"go-backend/internal/database/sqlc"
"go-backend/internal/services"
"log"
"net/http"
"text/template"
)
// UserService and AuthService are defined here, on the consumer side, so
@ -19,6 +22,7 @@ type UserService interface {
type AuthService interface {
CreateSession(ctx context.Context, userID string) (*services.SessionWithToken, error)
ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, error)
DeleteSession(ctx context.Context, token string) error
}
type Handler struct {
@ -32,3 +36,117 @@ func NewHandler(users UserService, auth AuthService) *Handler {
auth: auth,
}
}
func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if session == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
files := []string{
"./ui/html/base.tmpl",
"./ui/html/pages/index.tmpl",
}
ts, err := template.ParseFiles(files...)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if cookie != nil {
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if session != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
files := []string{
"./ui/html/base.tmpl",
"./ui/html/pages/login.tmpl",
}
ts, err := template.ParseFiles(files...)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if cookie != nil {
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if session != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
files := []string{
"./ui/html/base.tmpl",
"./ui/html/pages/signup.tmpl",
}
ts, err := template.ParseFiles(files...)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View file

@ -1,7 +1,6 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
@ -9,23 +8,19 @@ import (
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Username string
Password string
}
func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
var req LoginRequest
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
req := LoginRequest{
Username: r.PostFormValue("username"),
Password: r.PostFormValue("password"),
}
if req.Username == "" || req.Password == "" {
@ -67,6 +62,38 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, cookie)
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
}
func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
err = h.auth.DeleteSession(r.Context(), cookie.Value)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
cookie = &http.Cookie{
Name: "session",
Value: "",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Path: "/",
}
http.SetCookie(w, cookie)
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
}

View file

@ -1,161 +0,0 @@
package handlers
import (
"bytes"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"testing"
"go-backend/internal/auth"
"go-backend/internal/database"
"go-backend/internal/services"
"golang.org/x/crypto/argon2"
)
// fakeUsers and fakeAuth satisfy the UserService and AuthService interfaces
// declared in handler.go. No database, no network, no real hashing work —
// each test controls exactly what the "service layer" returns.
type fakeUsers struct {
user *database.User
err error
}
func (f *fakeUsers) FindByUsername(username string) (*database.User, error) {
return f.user, f.err
}
func (f *fakeUsers) FindByID(id string) (*database.User, error) {
return f.user, f.err
}
type fakeAuth struct {
session *services.SessionWithToken
err error
}
func (f *fakeAuth) CreateSession(userID string) (*auth.SessionWithToken, error) {
return f.session, f.err
}
func (f *fakeAuth) ValidateSessionToken(token string) (*auth.Session, error) {
return nil, nil
}
// hashPassword builds a real Argon2id hash in the format auth.VerifyPassword
// expects, so the "correct password" test case exercises the real
// verification logic rather than a stub.
func hashPassword(password, salt string) string {
saltBytes := []byte(salt)
hash := argon2.IDKey([]byte(password), saltBytes, 3, 65536, 4, 32)
return "$argon2id$v=19$m=65536,t=3,p=4$" +
base64.RawStdEncoding.EncodeToString(saltBytes) + "$" +
base64.RawStdEncoding.EncodeToString(hash)
}
func TestLoginHandler(t *testing.T) {
validUser := &database.User{ID: "user-1", Username: "alice", PasswordHash: hashPassword("correct-horse", "somesalt16bytes!")}
tests := []struct {
name string
body string
users UserService
auth AuthService
wantStatus int
}{
{
name: "successful login sets session cookie",
body: `{"username":"alice","password":"correct-horse"}`,
users: &fakeUsers{user: validUser},
auth: &fakeAuth{session: &services.SessionWithToken{Token: "abc.def"}},
wantStatus: http.StatusOK,
},
{
name: "wrong password rejected",
body: `{"username":"alice","password":"wrong-password"}`,
users: &fakeUsers{user: validUser},
auth: &fakeAuth{},
wantStatus: http.StatusBadRequest,
},
{
name: "unknown username rejected",
body: `{"username":"nobody","password":"whatever"}`,
users: &fakeUsers{user: nil},
auth: &fakeAuth{},
wantStatus: http.StatusBadRequest,
},
{
name: "missing fields rejected before hitting services",
body: `{"username":"","password":""}`,
users: &fakeUsers{err: errors.New("should never be called")},
auth: &fakeAuth{},
wantStatus: http.StatusBadRequest,
},
{
name: "user lookup failure returns 500",
body: `{"username":"alice","password":"correct-horse"}`,
users: &fakeUsers{err: errors.New("connection refused")},
auth: &fakeAuth{},
wantStatus: http.StatusInternalServerError,
},
{
name: "session creation failure returns 500",
body: `{"username":"alice","password":"correct-horse"}`,
users: &fakeUsers{user: validUser},
auth: &fakeAuth{err: errors.New("disk full")},
wantStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewHandler(tt.users, tt.auth)
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(tt.body))
w := httptest.NewRecorder()
h.LoginHandler(w, req)
if w.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String())
}
if tt.wantStatus == http.StatusOK {
resp := w.Result()
cookies := resp.Cookies()
if len(cookies) != 1 || cookies[0].Name != "session" || cookies[0].Value != "abc.def" {
t.Fatalf("expected session cookie with token abc.def, got %+v", cookies)
}
}
})
}
}
func TestLoginHandlerRejectsInvalidJSON(t *testing.T) {
h := NewHandler(&fakeUsers{}, &fakeAuth{})
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(`not-json`))
w := httptest.NewRecorder()
h.LoginHandler(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestLoginHandlerRejectsWrongMethod(t *testing.T) {
h := NewHandler(&fakeUsers{}, &fakeAuth{})
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
h.LoginHandler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}

View file

@ -1,17 +1,16 @@
package handlers
import (
"encoding/json"
"go-backend/internal/auth"
"go-backend/internal/database/sqlc"
"log"
"net/http"
)
type SignUpRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
type SignupRequest struct {
Username string
Password string
Email string
}
func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
@ -20,13 +19,10 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
return
}
var req SignUpRequest
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&req)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
req := SignupRequest{
Username: r.PostFormValue("username"),
Password: r.PostFormValue("password"),
Email: r.PostFormValue("email"),
}
if req.Username == "" || req.Password == "" || req.Email == "" {
@ -78,6 +74,7 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, cookie)
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
}

View file

@ -3,8 +3,6 @@ package handlers
import (
"encoding/json"
"net/http"
"go-backend/internal/auth"
)
type UserResponse struct {
@ -20,15 +18,14 @@ func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
}
// Get session token from cookie
cookies := auth.ParseCookies(r.Header.Get("Cookie"))
sessionToken, exists := cookies["session"]
if !exists {
cookie, err := r.Cookie("session")
if err != nil {
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
return
}
// Validate session
session, err := h.auth.ValidateSessionToken(r.Context(), sessionToken)
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return

View file

@ -94,3 +94,16 @@ func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (*
return &session, nil
}
func (s *AuthService) DeleteSession(ctx context.Context, token string) error {
tokenParts := strings.Split(token, ".")
if len(tokenParts) != 2 {
return nil
}
sessionID := tokenParts[0]
err := s.queries.DeleteSessionById(ctx, sessionID)
return err
}

28
ui/html/base.tmpl Normal file
View file

@ -0,0 +1,28 @@
{{define "base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{template "title" .}} - Golang template</title>
<!-- Link to the CSS stylesheet and favicon -->
<link rel="stylesheet" href="/static/css/main.css" />
<link
rel="shortcut icon"
href="/static/img/favicon.ico"
type="image/x-icon"
/>
<!-- Also link to some fonts hosted by Google -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700"
/>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<main>{{template "main" .}}</main>
<footer>Powered by <a href="https://go.dev/">Go</a></footer>
<!-- And include the JavaScript file -->
<script src="/static/js/main.js" type="text/javascript"></script>
</body>
</html>
{{end}}

6
ui/html/pages/index.tmpl Normal file
View file

@ -0,0 +1,6 @@
{{define "title"}}Home{{end}}
{{define "main"}}
<h1>Welcome</h1>
<button hx-post="/api/logout" class="btn">Logout</button>
{{end}}

38
ui/html/pages/login.tmpl Normal file
View file

@ -0,0 +1,38 @@
{{define "title"}}Welcome{{end}}
{{define "main"}}
<div class="container">
<h1>Log in</h1>
<form hx-post="/api/login" hx-target="#error-container">
<div class="form-group">
<div id="error-container" style="color: red; margin-bottom: 10px"></div>
<label for="username">Username:</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
name="password"
required
minlength="6"
/>
</div>
<button type="submit" class="btn">Login</button>
</form>
<p style="text-align: center; margin-top: 20px">
Don't have an account? <a href="/signup">Sign up here</a>
</p>
</div>
{{end}}

52
ui/html/pages/signup.tmpl Normal file
View file

@ -0,0 +1,52 @@
{{define "title"}}Welcome{{end}}
{{define "main"}}
<div class="container">
<h1>Register</h1>
<form hx-post="/api/signup" hx-target="#error-container">
<div class="form-group">
<div
id="error-container"
style="color: red; margin-bottom: 10px"
></div>
<label for="username">Username:</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
/>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input
type="password"
id="password"
name="password"
required
minlength="6"
/>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input
type="text"
id="email"
name="email"
required
/>
</div>
<button type="submit" class="btn">Register</button>
</form>
<p style="text-align: center; margin-top: 20px">
Already have an account? <a href="/login">Login here</a>
</p>
</div>
{{end}}

10
ui/static/js/main.js Normal file
View file

@ -0,0 +1,10 @@
// By default htmx only swaps 2xx/304 responses into hx-target; 4xx/5xx
// responses are treated as errors and left unswapped. Our API handlers
// return error status codes with a plain-text body meant for the
// error-container div, so force the swap to happen anyway.
document.body.addEventListener("htmx:beforeSwap", function (evt) {
if (evt.detail.xhr.status >= 400) {
evt.detail.shouldSwap = true;
evt.detail.isError = false;
}
});