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:
parent
13d5156e06
commit
ff2d6f9c9c
14 changed files with 400 additions and 86 deletions
|
|
@ -12,7 +12,7 @@ The intention for this project is to work as a template, where the developer sho
|
||||||
- CORS support
|
- CORS support
|
||||||
- Request logging middleware
|
- Request logging middleware
|
||||||
- Native Go HTTP server (no external frameworks)
|
- Native Go HTTP server (no external frameworks)
|
||||||
- HTMX for interactivity
|
- HTMX and ALPINE.js for front-end interactivity
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
|
||||||
14
cmd/main.go
14
cmd/main.go
|
|
@ -57,20 +57,26 @@ func main() {
|
||||||
userService := services.NewUserService(db, queries)
|
userService := services.NewUserService(db, queries)
|
||||||
authService := services.NewAuthService(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 := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static"))))
|
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("GET /login", handler.LoginPageHandler)
|
||||||
mux.HandleFunc("/user", handler.UserHandler)
|
mux.HandleFunc("GET /signup", handler.SignUpPageHandler)
|
||||||
mux.HandleFunc("/signup", handler.SignUpPageHandler)
|
mux.HandleFunc("GET /settings", handler.SettingsPageHandler)
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/login", handler.LoginHandler)
|
mux.HandleFunc("POST /api/login", handler.LoginHandler)
|
||||||
mux.HandleFunc("POST /api/logout", handler.LogoutHandler)
|
mux.HandleFunc("POST /api/logout", handler.LogoutHandler)
|
||||||
mux.HandleFunc("POST /api/signup", handler.SignUpHandler)
|
mux.HandleFunc("POST /api/signup", handler.SignUpHandler)
|
||||||
|
mux.HandleFunc("PATCH /api/user", handler.PatchUserHandler)
|
||||||
|
|
||||||
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))
|
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,11 @@ WHERE username = $1;
|
||||||
INSERT INTO user_auth (username, email, password_hash)
|
INSERT INTO user_auth (username, email, password_hash)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, username, email, password_hash, created_at, updated_at;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ package sqlc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertUser = `-- name: InsertUser :one
|
const insertUser = `-- name: InsertUser :one
|
||||||
|
|
@ -35,6 +36,41 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (UserAut
|
||||||
return i, err
|
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
|
const selectUserById = `-- name: SelectUserById :one
|
||||||
SELECT id, username, email, password_hash, created_at, updated_at
|
SELECT id, username, email, password_hash, created_at, updated_at
|
||||||
FROM user_auth
|
FROM user_auth
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import (
|
||||||
"go-backend/internal/auth"
|
"go-backend/internal/auth"
|
||||||
"go-backend/internal/database/sqlc"
|
"go-backend/internal/database/sqlc"
|
||||||
"go-backend/internal/services"
|
"go-backend/internal/services"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"text/template"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserService and AuthService are defined here, on the consumer side, so
|
// 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)
|
FindByID(ctx context.Context, id string) (*sqlc.UserAuth, error)
|
||||||
FindByUsername(ctx context.Context, username string) (*sqlc.UserAuth, error)
|
FindByUsername(ctx context.Context, username string) (*sqlc.UserAuth, error)
|
||||||
CreateUser(ctx context.Context, userInput sqlc.InsertUserParams) (*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 {
|
type AuthService interface {
|
||||||
|
|
@ -27,18 +28,44 @@ type AuthService interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
users UserService
|
users UserService
|
||||||
auth AuthService
|
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{
|
return &Handler{
|
||||||
users: users,
|
users: users,
|
||||||
auth: auth,
|
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")
|
cookie, err := r.Cookie("session")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -69,27 +96,11 @@ func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
files := []string{
|
h.render(w, "index.tmpl", PageData{User: user, Data: user.Username})
|
||||||
"./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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) LoginPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie, err := r.Cookie("session")
|
cookie, _ := r.Cookie("session")
|
||||||
|
|
||||||
if cookie != nil {
|
if cookie != nil {
|
||||||
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
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{
|
h.render(w, "login.tmpl", PageData{})
|
||||||
"./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) {
|
func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie, err := r.Cookie("session")
|
cookie, _ := r.Cookie("session")
|
||||||
|
|
||||||
if cookie != nil {
|
if cookie != nil {
|
||||||
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
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{
|
h.render(w, "signup.tmpl", PageData{})
|
||||||
"./ui/html/base.tmpl",
|
}
|
||||||
"./ui/html/pages/signup.tmpl",
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Print(err.Error())
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ts.ExecuteTemplate(w, "base", nil)
|
if session == nil {
|
||||||
if err != nil {
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
log.Print(err.Error())
|
return
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
internal/handlers/templates.go
Normal file
38
internal/handlers/templates.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"errors"
|
||||||
"go-backend/internal/auth"
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/services"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -12,53 +15,105 @@ type UserResponse struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
|
// PatchUserHandler applies a partial update to the logged-in user. A field
|
||||||
if r.Method != http.MethodGet {
|
// is only touched when its form key is present in the request body — an
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
// omitted key leaves that column unchanged, while an empty value is treated
|
||||||
return
|
// as an explicit (and rejected) attempt to blank out a required field.
|
||||||
}
|
func (h *Handler) PatchUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Get session token from cookie
|
|
||||||
cookie, err := r.Cookie("session")
|
cookie, err := r.Cookie("session")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session
|
|
||||||
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
session, bump, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if session == nil {
|
if session == nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if bump {
|
if bump {
|
||||||
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
|
http.SetCookie(w, auth.NewSessionCookie(cookie.Value, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user info
|
if err := r.ParseForm(); err != nil {
|
||||||
user, err := h.users.FindByID(r.Context(), session.UserID)
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
switch {
|
||||||
return
|
case errors.Is(err, services.ErrCurrentPasswordRequired), errors.Is(err, services.ErrInvalidCurrentPassword):
|
||||||
}
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
if user == nil {
|
default:
|
||||||
http.Error(w, "User not found", http.StatusNotFound)
|
log.Printf("Error patching user: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := UserResponse{
|
// The settings page targets the request's hx-target (a per-field error
|
||||||
ID: user.ID,
|
// container) by default, so a plain success body would land there. Any
|
||||||
Username: user.Username,
|
// field actually changed is instead pushed out-of-band to the element
|
||||||
Email: user.Email,
|
// 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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
json.NewEncoder(w).Encode(resp)
|
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}}`,
|
||||||
|
))
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
"go-backend/internal/database/sqlc"
|
"go-backend/internal/database/sqlc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -55,3 +56,59 @@ func (s *UserService) CreateUser(ctx context.Context, userInput sqlc.InsertUserP
|
||||||
|
|
||||||
return &user, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,13 @@
|
||||||
href="https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700"
|
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/htmx.org@1.9.10"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script src="/static/js/main.js" type="text/javascript"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
7
ui/html/navbar.tmpl
Normal file
7
ui/html/navbar.tmpl
Normal 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}}
|
||||||
|
|
@ -2,5 +2,4 @@
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<h1>Welcome {{.}}</h1>
|
<h1>Welcome {{.}}</h1>
|
||||||
<button hx-post="/api/logout" class="btn">Logout</button>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{{define "title"}}Welcome{{end}}
|
{{define "title"}}Login{{end}}
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
|
||||||
91
ui/html/pages/settings.tmpl
Normal file
91
ui/html/pages/settings.tmpl
Normal 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}}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{{define "title"}}Welcome{{end}}
|
{{define "title"}}Sign up{{end}}
|
||||||
|
|
||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue