frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)
This commit is contained in:
18
frontend/internal/auth/passwords.go
Normal file
18
frontend/internal/auth/passwords.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Package auth provides password hashing and session handling for WhereWoof.
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword returns a bcrypt hash of the given plaintext password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CheckPassword reports whether the plaintext password matches the bcrypt hash.
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
51
frontend/internal/auth/session.go
Normal file
51
frontend/internal/auth/session.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
// SessionName is the cookie name for authenticated sessions.
|
||||
const SessionName = "ww_session"
|
||||
|
||||
// Store is the signed + encrypted cookie store, initialised by InitSessionStore.
|
||||
var Store *sessions.CookieStore
|
||||
|
||||
// InitSessionStore creates the cookie store with the server secret.
|
||||
func InitSessionStore(secret string) {
|
||||
Store = sessions.NewCookieStore([]byte(secret))
|
||||
Store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 30 * 24 * 3600, // 30 days
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user id from the request, if any.
|
||||
func GetUserID(r *http.Request) (int64, bool) {
|
||||
if Store == nil {
|
||||
return 0, false
|
||||
}
|
||||
s, err := Store.Get(r, SessionName)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, ok := s.Values["user_id"].(int64)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// SetUserID records the user id in the session cookie.
|
||||
func SetUserID(w http.ResponseWriter, r *http.Request, id int64) error {
|
||||
s, _ := Store.Get(r, SessionName)
|
||||
s.Values["user_id"] = id
|
||||
return s.Save(r, w)
|
||||
}
|
||||
|
||||
// Clear destroys the session (logout).
|
||||
func Clear(w http.ResponseWriter, r *http.Request) error {
|
||||
s, _ := Store.Get(r, SessionName)
|
||||
s.Options.MaxAge = -1
|
||||
return s.Save(r, w)
|
||||
}
|
||||
32
frontend/internal/db/db.go
Normal file
32
frontend/internal/db/db.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
44
frontend/internal/db/models.go
Normal file
44
frontend/internal/db/models.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Scan struct {
|
||||
ID int64 `json:"id"`
|
||||
TagID int64 `json:"tag_id"`
|
||||
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
|
||||
Lat pgtype.Float8 `json:"lat"`
|
||||
Lng pgtype.Float8 `json:"lng"`
|
||||
LocationShared bool `json:"location_shared"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
AlertSent bool `json:"alert_sent"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID int64 `json:"id"`
|
||||
TagCode string `json:"tag_code"`
|
||||
OwnerID pgtype.Int8 `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
ItemType pgtype.Text `json:"item_type"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
PhotoUrl pgtype.Text `json:"photo_url"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Address pgtype.Text `json:"address"`
|
||||
Notes pgtype.Text `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
28
frontend/internal/db/querier.go
Normal file
28
frontend/internal/db/querier.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
|
||||
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
|
||||
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
|
||||
GetTagByID(ctx context.Context, id int64) (Tag, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
GetUserByID(ctx context.Context, id int64) (User, error)
|
||||
InsertTag(ctx context.Context, tagCode string) (Tag, error)
|
||||
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
|
||||
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
|
||||
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
47
frontend/internal/db/queries.sql
Normal file
47
frontend/internal/db/queries.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (email, password_hash, name, phone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT * FROM users WHERE email = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT * FROM users WHERE id = $1;
|
||||
|
||||
-- name: ListTagsByOwner :many
|
||||
SELECT * FROM tags WHERE owner_id = $1 ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetTagByCode :one
|
||||
SELECT * FROM tags WHERE tag_code = $1;
|
||||
|
||||
-- name: GetTagByID :one
|
||||
SELECT * FROM tags WHERE id = $1;
|
||||
|
||||
-- name: CountTagsByOwner :one
|
||||
SELECT count(*) FROM tags WHERE owner_id = $1;
|
||||
|
||||
-- name: BindTag :one
|
||||
UPDATE tags
|
||||
SET owner_id = $1, updated_at = now()
|
||||
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateTagDetails :one
|
||||
UPDATE tags
|
||||
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearTagOwner :one
|
||||
UPDATE tags
|
||||
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetTagStatus :exec
|
||||
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
|
||||
|
||||
-- name: InsertTag :one
|
||||
INSERT INTO tags (tag_code) VALUES ($1)
|
||||
RETURNING *;
|
||||
319
frontend/internal/db/queries.sql.go
Normal file
319
frontend/internal/db/queries.sql.go
Normal file
@@ -0,0 +1,319 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: queries.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const bindTag = `-- name: BindTag :one
|
||||
UPDATE tags
|
||||
SET owner_id = $1, updated_at = now()
|
||||
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type BindTagParams struct {
|
||||
OwnerID pgtype.Int8 `json:"owner_id"`
|
||||
TagCode string `json:"tag_code"`
|
||||
}
|
||||
|
||||
func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, bindTag, arg.OwnerID, arg.TagCode)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const clearTagOwner = `-- name: ClearTagOwner :one
|
||||
UPDATE tags
|
||||
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, clearTagOwner, id)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countTagsByOwner = `-- name: CountTagsByOwner :one
|
||||
SELECT count(*) FROM tags WHERE owner_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countTagsByOwner, ownerID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (email, password_hash, name, phone)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, password_hash, name, phone, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, createUser,
|
||||
arg.Email,
|
||||
arg.PasswordHash,
|
||||
arg.Name,
|
||||
arg.Phone,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Phone,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTagByCode = `-- name: GetTagByCode :one
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE tag_code = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, getTagByCode, tagCode)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTagByID = `-- name: GetTagByID :one
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, getTagByID, id)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Phone,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.Phone,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertTag = `-- name: InsertTag :one
|
||||
INSERT INTO tags (tag_code) VALUES ($1)
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, insertTag, tagCode)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listTagsByOwner = `-- name: ListTagsByOwner :many
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
|
||||
rows, err := q.db.Query(ctx, listTagsByOwner, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Tag
|
||||
for rows.Next() {
|
||||
var i Tag
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setTagStatus = `-- name: SetTagStatus :exec
|
||||
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
|
||||
`
|
||||
|
||||
type SetTagStatusParams struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) error {
|
||||
_, err := q.db.Exec(ctx, setTagStatus, arg.ID, arg.Status)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateTagDetails = `-- name: UpdateTagDetails :one
|
||||
UPDATE tags
|
||||
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateTagDetailsParams struct {
|
||||
ID int64 `json:"id"`
|
||||
ItemType pgtype.Text `json:"item_type"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
PhotoUrl pgtype.Text `json:"photo_url"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Address pgtype.Text `json:"address"`
|
||||
Notes pgtype.Text `json:"notes"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
|
||||
row := q.db.QueryRow(ctx, updateTagDetails,
|
||||
arg.ID,
|
||||
arg.ItemType,
|
||||
arg.Description,
|
||||
arg.PhotoUrl,
|
||||
arg.Phone,
|
||||
arg.Address,
|
||||
arg.Notes,
|
||||
)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagCode,
|
||||
&i.OwnerID,
|
||||
&i.Status,
|
||||
&i.ItemType,
|
||||
&i.Description,
|
||||
&i.PhotoUrl,
|
||||
&i.Phone,
|
||||
&i.Address,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
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