frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)

This commit is contained in:
2026-08-05 13:46:50 +10:00
parent b846c2c58e
commit 133e375b6b
31 changed files with 1591 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
func (a *App) RegisterPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "register", "Create account", nil, "")
}
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
name := strings.TrimSpace(r.FormValue("name"))
if email == "" || password == "" {
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
return
}
if len(password) < 8 {
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
return
}
if len([]byte(password)) > 72 {
a.render(w, r, "register", "Create account", nil, "Password must be 72 bytes or fewer.")
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := a.Queries.CreateUser(r.Context(), db.CreateUserParams{
Email: email,
PasswordHash: hash,
Name: pgtype.Text{String: name, Valid: name != ""},
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
a.render(w, r, "register", "Create account", nil, "That email is already registered.")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := auth.SetUserID(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "login", "Log in", nil, "")
}
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
user, err := a.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
return
}
if !auth.CheckPassword(user.PasswordHash, password) {
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
return
}
if err := auth.SetUserID(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
_ = auth.Clear(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -0,0 +1,103 @@
// Package handlers wires templates, auth, and database queries for the
// WhereWoof GOAT front-end.
package handlers
import (
"html/template"
"net/http"
"strings"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
// Templates maps a page key to its parsed template set (base + page + partials).
// Each page is parsed as its own set so the shared "content" block name
// doesn't collide across pages.
type Templates map[string]*template.Template
// App holds dependencies shared by all handlers.
type App struct {
Queries *db.Queries
Tpl Templates
}
// New returns an App with the given query layer and template sets.
func New(queries *db.Queries, tpl Templates) *App {
return &App{Queries: queries, Tpl: tpl}
}
// PageData is the root data passed to the base layout.
type PageData struct {
CurrentUser *db.User
Title string
Error string
Data any
}
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// LoadTemplates parses every page's template set from templates/.
func LoadTemplates() (Templates, error) {
const dir = "templates"
base := dir + "/base.html"
pages := map[string][]string{
"index": {dir + "/index.html"},
"register": {dir + "/register.html"},
"login": {dir + "/login.html"},
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html"},
"edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"},
"notfound": {dir + "/not-found.html"},
}
funcs := template.FuncMap{"title": titleCase}
tpl := make(Templates, len(pages))
for name, files := range pages {
paths := append([]string{base}, files...)
t, err := template.New(name).Funcs(funcs).ParseFiles(paths...)
if err != nil {
return nil, err
}
tpl[name] = t
}
return tpl, nil
}
// render executes the page's base layout with a PageData populated from the
// authenticated user (if any).
func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, data any, errMsg string) {
pd := PageData{Title: title, Data: data, Error: errMsg}
if uid, ok := auth.GetUserID(r); ok {
if u, err := a.Queries.GetUserByID(r.Context(), uid); err == nil {
pd.CurrentUser = &u
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Tpl[page].ExecuteTemplate(w, "base", pd); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
// renderPartial executes a named partial (e.g. "account-panel") for HTMX swaps.
func (a *App) renderPartial(w http.ResponseWriter, page, partial string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Tpl[page].ExecuteTemplate(w, partial, data); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
// RequireAuth redirects unauthenticated requests to /login.
func (a *App) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.GetUserID(r); !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
)
type publicData struct {
ID int64
TagCode string
Status string
ItemType pgtype.Text
Description pgtype.Text
PhotoUrl pgtype.Text
Phone pgtype.Text
Address pgtype.Text
Notes pgtype.Text
IsOwner bool
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "index", "Home", nil, "")
}
// PublicTag renders the unauthenticated tag page: setup prompt, return details,
// or unavailable (suspended / not found).
func (a *App) PublicTag(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("tag_code")
tag, err := a.Queries.GetTagByCode(r.Context(), code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.render(w, r, "notfound", "Tag not found", nil, "")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
pd := publicData{
ID: tag.ID,
TagCode: tag.TagCode,
Status: tag.Status,
ItemType: tag.ItemType,
Description: tag.Description,
PhotoUrl: tag.PhotoUrl,
Phone: tag.Phone,
Address: tag.Address,
Notes: tag.Notes,
}
if uid, ok := auth.GetUserID(r); ok && tag.OwnerID.Valid && tag.OwnerID.Int64 == uid {
pd.IsOwner = true
}
a.render(w, r, "public", "Found item", pd, "")
}

View File

@@ -0,0 +1,152 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
const maxTagsPerAccount = 20
type accountData struct {
Tags []db.Tag
AddError string
}
func ownerID(uid int64) pgtype.Int8 {
return pgtype.Int8{Int64: uid, Valid: true}
}
func (a *App) Account(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.render(w, r, "account", "My Tags", accountData{Tags: tags}, "")
}
// AddTag binds a tag code to the current account (HTMX: returns account-panel).
func (a *App) AddTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
code := strings.ToUpper(strings.TrimSpace(r.FormValue("tag_code")))
oid := ownerID(uid)
data := accountData{}
if code == "" {
data.AddError = "Enter a tag code."
} else {
cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if cnt >= maxTagsPerAccount {
data.AddError = "Limit reached: each account can hold 20 tags."
} else {
_, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
data.AddError = "Tag not found, or already claimed by another account."
} else {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
}
}
tags, err := a.Queries.ListTagsByOwner(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data.Tags = tags
a.renderPartial(w, "account", "account-panel", data)
}
func (a *App) EditTagPage(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "suspended" {
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
return
}
a.render(w, r, "edit", "Edit tag", tag, "")
}
func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "suspended" {
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
return
}
params := db.UpdateTagDetailsParams{
ID: tag.ID,
ItemType: textOrNil(strings.TrimSpace(r.FormValue("item_type"))),
Description: textOrNil(strings.TrimSpace(r.FormValue("description"))),
PhotoUrl: textOrNil(strings.TrimSpace(r.FormValue("photo_url"))),
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
}
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
// DeleteTag reverts a tag to unset so it can be re-bound (HTMX: account-panel).
func (a *App) DeleteTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if _, err := a.Queries.ClearTagOwner(r.Context(), tag.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.renderPartial(w, "account", "account-panel", accountData{Tags: tags})
}
// loadOwnedTag fetches a tag by path id and verifies it belongs to uid.
func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (db.Tag, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return db.Tag{}, false
}
tag, err := a.Queries.GetTagByID(r.Context(), id)
if err != nil || !tag.OwnerID.Valid || tag.OwnerID.Int64 != uid {
http.NotFound(w, r)
return db.Tag{}, false
}
return tag, true
}
func textOrNil(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: s != ""}
}