frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)
This commit is contained in:
98
frontend/internal/handlers/auth.go
Normal file
98
frontend/internal/handlers/auth.go
Normal 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)
|
||||
}
|
||||
103
frontend/internal/handlers/handlers.go
Normal file
103
frontend/internal/handlers/handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
frontend/internal/handlers/tag_page.go
Normal file
59
frontend/internal/handlers/tag_page.go
Normal 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, "")
|
||||
}
|
||||
152
frontend/internal/handlers/tags.go
Normal file
152
frontend/internal/handlers/tags.go
Normal 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 != ""}
|
||||
}
|
||||
Reference in New Issue
Block a user