feat: Add a navbar. Add a settings page, forms to update username, email, password. Add the needed sql queries, service and handler functions. Remove the not needed GET /user endpoint.

This commit is contained in:
Leo 2026-08-21 11:40:32 +03:00
parent 13d5156e06
commit ff2d6f9c9c
14 changed files with 400 additions and 86 deletions

View file

@ -12,7 +12,7 @@ The intention for this project is to work as a template, where the developer sho
- CORS support
- Request logging middleware
- Native Go HTTP server (no external frameworks)
- HTMX for interactivity
- HTMX and ALPINE.js for front-end interactivity
## Requirements

View file

@ -57,20 +57,26 @@ func main() {
userService := services.NewUserService(db, queries)
authService := services.NewAuthService(db, queries)
handler := handlers.NewHandler(userService, authService)
templateCache, err := handlers.NewTemplateCache()
if err != nil {
log.Fatal("Failed to parse templates:", err)
}
handler := handlers.NewHandler(userService, authService, templateCache)
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static"))))
mux.HandleFunc("GET /{$}", handler.HomeHandler)
mux.HandleFunc("GET /{$}", handler.HomePageHandler)
mux.HandleFunc("GET /login", handler.LoginPageHandler)
mux.HandleFunc("/user", handler.UserHandler)
mux.HandleFunc("/signup", handler.SignUpPageHandler)
mux.HandleFunc("GET /signup", handler.SignUpPageHandler)
mux.HandleFunc("GET /settings", handler.SettingsPageHandler)
mux.HandleFunc("POST /api/login", handler.LoginHandler)
mux.HandleFunc("POST /api/logout", handler.LogoutHandler)
mux.HandleFunc("POST /api/signup", handler.SignUpHandler)
mux.HandleFunc("PATCH /api/user", handler.PatchUserHandler)
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))

View file

@ -12,3 +12,11 @@ WHERE username = $1;
INSERT INTO user_auth (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, username, email, password_hash, created_at, updated_at;
-- name: PatchUser :one
UPDATE user_auth
SET username = COALESCE(sqlc.narg(username), username),
email = COALESCE(sqlc.narg(email), email),
password_hash = COALESCE(sqlc.narg(password_hash), password_hash)
WHERE id = sqlc.arg(id)
RETURNING id, username, email, password_hash, created_at, updated_at;

View file

@ -7,6 +7,7 @@ package sqlc
import (
"context"
"database/sql"
)
const insertUser = `-- name: InsertUser :one
@ -35,6 +36,41 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (UserAut
return i, err
}
const patchUser = `-- name: PatchUser :one
UPDATE user_auth
SET username = COALESCE($1, username),
email = COALESCE($2, email),
password_hash = COALESCE($3, password_hash)
WHERE id = $4
RETURNING id, username, email, password_hash, created_at, updated_at
`
type PatchUserParams struct {
Username sql.NullString
Email sql.NullString
PasswordHash sql.NullString
ID string
}
func (q *Queries) PatchUser(ctx context.Context, arg PatchUserParams) (UserAuth, error) {
row := q.db.QueryRowContext(ctx, patchUser,
arg.Username,
arg.Email,
arg.PasswordHash,
arg.ID,
)
var i UserAuth
err := row.Scan(
&i.ID,
&i.Username,
&i.Email,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const selectUserById = `-- name: SelectUserById :one
SELECT id, username, email, password_hash, created_at, updated_at
FROM user_auth

View file

@ -5,9 +5,9 @@ import (
"go-backend/internal/auth"
"go-backend/internal/database/sqlc"
"go-backend/internal/services"
"html/template"
"log"
"net/http"
"text/template"
)
// UserService and AuthService are defined here, on the consumer side, so
@ -18,6 +18,7 @@ type UserService interface {
FindByID(ctx context.Context, id string) (*sqlc.UserAuth, error)
FindByUsername(ctx context.Context, username string) (*sqlc.UserAuth, error)
CreateUser(ctx context.Context, userInput sqlc.InsertUserParams) (*sqlc.UserAuth, error)
PatchUser(ctx context.Context, id string, input services.PatchUserInput) (*sqlc.UserAuth, error)
}
type AuthService interface {
@ -27,18 +28,44 @@ type AuthService interface {
}
type Handler struct {
users UserService
auth AuthService
users UserService
auth AuthService
templateCache map[string]*template.Template
}
func NewHandler(users UserService, auth AuthService) *Handler {
// PageData wraps every page render so base.tmpl always has a stable place
// to check whether a user is logged in (.User), regardless of what
// page-specific data (.Data) the page template itself needs.
type PageData struct {
User *sqlc.UserAuth
Data any
}
func NewHandler(users UserService, auth AuthService, templateCache map[string]*template.Template) *Handler {
return &Handler{
users: users,
auth: auth,
users: users,
auth: auth,
templateCache: templateCache,
}
}
func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
// render looks up the pre-parsed template for page (e.g. "index.tmpl") and
// executes it. It writes an error response itself on failure.
func (h *Handler) render(w http.ResponseWriter, page string, data PageData) {
ts, ok := h.templateCache[page]
if !ok {
log.Printf("template %s not found in cache", page)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if err := ts.ExecuteTemplate(w, "base", data); err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HomePageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
@ -69,27 +96,11 @@ func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
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", user.Username)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
h.render(w, "index.tmpl", PageData{User: user, Data: user.Username})
}
func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
cookie, _ := r.Cookie("session")
if cookie != nil {
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
@ -110,27 +121,11 @@ func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
}
}
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)
}
h.render(w, "login.tmpl", PageData{})
}
func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
cookie, _ := r.Cookie("session")
if cookie != nil {
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
@ -151,21 +146,39 @@ func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
}
}
files := []string{
"./ui/html/base.tmpl",
"./ui/html/pages/signup.tmpl",
h.render(w, "signup.tmpl", PageData{})
}
func (h *Handler) SettingsPageHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ts, err := template.ParseFiles(files...)
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
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)
if session == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
user, err := h.users.FindByID(r.Context(), session.UserID)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
h.render(w, "settings.tmpl", PageData{User: user, Data: user})
}

View file

@ -0,0 +1,38 @@
package handlers
import (
"html/template"
"path/filepath"
)
// NewTemplateCache parses every page template once, each paired with the
// shared layout, and returns them keyed by page filename (e.g. "index.tmpl").
// Handlers look up the pre-parsed template instead of re-parsing files on
// every request.
func NewTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}
pages, err := filepath.Glob("./ui/html/pages/*.tmpl")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
files := []string{
"./ui/html/base.tmpl",
"./ui/html/navbar.tmpl",
page,
}
ts, err := template.ParseFiles(files...)
if err != nil {
return nil, err
}
cache[name] = ts
}
return cache, nil
}

View file

@ -1,8 +1,11 @@
package handlers
import (
"encoding/json"
"errors"
"go-backend/internal/auth"
"go-backend/internal/services"
"html/template"
"log"
"net/http"
)
@ -12,53 +15,105 @@ type UserResponse struct {
Email string `json:"email"`
}
func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get session token from cookie
// PatchUserHandler applies a partial update to the logged-in user. A field
// is only touched when its form key is present in the request body — an
// omitted key leaves that column unchanged, while an empty value is treated
// as an explicit (and rejected) attempt to blank out a required field.
func (h *Handler) PatchUserHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
return
}
// Validate session
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if bump {
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
}
// Get user info
user, err := h.users.FindByID(r.Context(), session.UserID)
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
input := services.PatchUserInput{}
if r.PostForm.Has("username") {
v := r.PostFormValue("username")
if v == "" {
http.Error(w, "Username cannot be empty", http.StatusBadRequest)
return
}
input.Username = &v
}
if r.PostForm.Has("email") {
v := r.PostFormValue("email")
if v == "" {
http.Error(w, "Email cannot be empty", http.StatusBadRequest)
return
}
input.Email = &v
}
if r.PostForm.Has("current_password") {
v := r.PostFormValue("current_password")
input.CurrentPassword = &v
}
if r.PostForm.Has("new_password") {
v := r.PostFormValue("new_password")
if v == "" {
http.Error(w, "New password cannot be empty", http.StatusBadRequest)
return
}
input.NewPassword = &v
}
user, err := h.users.PatchUser(r.Context(), session.UserID, input)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "User not found", http.StatusNotFound)
switch {
case errors.Is(err, services.ErrCurrentPasswordRequired), errors.Is(err, services.ErrInvalidCurrentPassword):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
log.Printf("Error patching user: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
resp := UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
// The settings page targets the request's hx-target (a per-field error
// container) by default, so a plain success body would land there. Any
// field actually changed is instead pushed out-of-band to the element
// that displays its value, leaving the error container's swap empty.
data := patchUserSuccessData{PasswordChanged: input.NewPassword != nil}
if input.Username != nil {
data.Username = &user.Username
}
if input.Email != nil {
data.Email = &user.Email
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
patchUserSuccessTmpl.Execute(w, data)
}
type patchUserSuccessData struct {
Username *string
Email *string
PasswordChanged bool
}
var patchUserSuccessTmpl = template.Must(template.New("patchUserSuccess").Parse(`` +
`{{if .Username}}<p id="username-display" x-show="!editingUsername" hx-swap-oob="true" style="margin: 0"><strong>Username:</strong> {{.Username}}</p>{{end}}` +
`{{if .Email}}<p id="email-display" x-show="!editingEmail" hx-swap-oob="true" style="margin: 0"><strong>Email:</strong> {{.Email}}</p>{{end}}` +
`{{if .PasswordChanged}}<span style="color: green">Password updated.</span>{{end}}`,
))

View file

@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"go-backend/internal/auth"
"go-backend/internal/database/sqlc"
)
@ -55,3 +56,59 @@ func (s *UserService) CreateUser(ctx context.Context, userInput sqlc.InsertUserP
return &user, nil
}
var (
ErrCurrentPasswordRequired = errors.New("current password is required to set a new password")
ErrInvalidCurrentPassword = errors.New("current password is incorrect")
)
// PatchUserInput carries a partial update: a nil field means "leave this
// field alone", distinguishing "not provided" from a provided zero value,
// since Go has no nullish type to express that on its own.
type PatchUserInput struct {
Username *string
Email *string
CurrentPassword *string
NewPassword *string
}
func (s *UserService) PatchUser(ctx context.Context, id string, patchUserInput PatchUserInput) (*sqlc.UserAuth, error) {
params := sqlc.PatchUserParams{ID: id}
if patchUserInput.Username != nil {
params.Username = sql.NullString{String: *patchUserInput.Username, Valid: true}
}
if patchUserInput.Email != nil {
params.Email = sql.NullString{String: *patchUserInput.Email, Valid: true}
}
if patchUserInput.NewPassword != nil {
if patchUserInput.CurrentPassword == nil {
return nil, ErrCurrentPasswordRequired
}
user, err := s.queries.SelectUserById(ctx, id)
if err != nil {
return nil, err
}
if !auth.VerifyPassword(user.PasswordHash, *patchUserInput.CurrentPassword) {
return nil, ErrInvalidCurrentPassword
}
newHash, err := auth.HashPassword(*patchUserInput.NewPassword)
if err != nil {
return nil, err
}
params.PasswordHash = sql.NullString{String: newHash, Valid: true}
}
user, err := s.queries.PatchUser(ctx, params)
if err != nil {
return nil, err
}
return &user, nil
}

View file

@ -17,9 +17,13 @@
href="https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700"
/>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js" defer></script>
</head>
<body>
<main>{{template "main" .}}</main>
{{if .User}}
{{template "navbar"}}
{{end}}
<main>{{template "main" .Data}}</main>
<script src="/static/js/main.js" type="text/javascript"></script>
</body>
</html>

7
ui/html/navbar.tmpl Normal file
View file

@ -0,0 +1,7 @@
{{define "navbar"}}
<nav class="navbar">
<a href="/">Home</a>
<a href="/settings">Settings</a>
<button hx-post="/api/logout" class="btn">Logout</button>
</nav>
{{end}}

View file

@ -2,5 +2,4 @@
{{define "main"}}
<h1>Welcome {{.}}</h1>
<button hx-post="/api/logout" class="btn">Logout</button>
{{end}}

View file

@ -1,4 +1,4 @@
{{define "title"}}Welcome{{end}}
{{define "title"}}Login{{end}}
{{define "main"}}
<div class="container">

View file

@ -0,0 +1,91 @@
{{define "title"}}Settings{{end}}
{{define "main"}}
<div class="container">
<h1>Settings</h1>
<div x-data="{ editingUsername: false}" style="display: flex; align-items: center; gap: 10px">
<p id="username-display" x-show="!editingUsername" style="margin: 0">
<strong>Username:</strong> {{.Username}}
</p>
<form
x-show="editingUsername"
hx-patch="/api/user"
hx-target="#username-error-container"
@htmx:after-request="if (event.detail.successful) editingUsername = false"
style="display: flex; align-items: center; gap: 10px; margin: 0"
>
<label for="username" class="sr-only">Username:</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
value="{{.Username}}"
/>
<button type="submit" class="btn">Save</button>
</form>
<button type="button" class="btn" @click="editingUsername = !editingUsername" x-text="editingUsername ? 'Cancel' : 'Edit'"></button>
</div>
<div id="username-error-container" style="color: red; margin-bottom: 10px"></div>
<div x-data="{ editingEmail: false}" style="display: flex; align-items: center; gap: 10px">
<p id="email-display" x-show="!editingEmail" style="margin: 0">
<strong>Email:</strong> {{.Email}}
</p>
<form
x-show="editingEmail"
hx-patch="/api/user"
hx-target="#email-error-container"
@htmx:after-request="if (event.detail.successful) editingEmail = false"
style="display: flex; align-items: center; gap: 10px; margin: 0"
>
<label for="email" class="sr-only">Username:</label>
<input
type="text"
id="email"
name="email"
required
minlength="3"
value="{{.Email}}"
/>
<button type="submit" class="btn">Save</button>
</form>
<button type="button" class="btn" @click="editingEmail = !editingEmail" x-text="editingEmail ? 'Cancel' : 'Edit'"></button>
</div>
<div id="email-error-container" style="color: red; margin-bottom: 10px"></div>
<h2>Change password</h2>
<form hx-patch="/api/user" hx-target="#password-error-container">
<div class="form-group">
<div id="password-error-container" style="color: red; margin-bottom: 10px"></div>
<label for="current-password">Current password:</label>
<input
type="password"
id="current-password"
name="current_password"
required
minlength="6"
/>
</div>
<div class="form-group">
<label for="new-password">New password:</label>
<input
type="password"
id="new-password"
name="new_password"
required
minlength="6"
/>
</div>
<button type="submit" class="btn">Update password</button>
</form>
</div>
{{end}}

View file

@ -1,4 +1,4 @@
{{define "title"}}Welcome{{end}}
{{define "title"}}Sign up{{end}}
{{define "main"}}
<div class="container">