Add Homepage, login, signup page UI. Add logout functionality
This commit is contained in:
parent
e1b63d5deb
commit
1d70b3a6d3
13 changed files with 325 additions and 210 deletions
11
cmd/main.go
11
cmd/main.go
|
|
@ -37,9 +37,16 @@ func main() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
mux.HandleFunc("/login", handler.LoginHandler)
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./ui/static"))))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /{$}", handler.HomeHandler)
|
||||||
|
mux.HandleFunc("GET /login", handler.LoginPageHandler)
|
||||||
mux.HandleFunc("/user", handler.UserHandler)
|
mux.HandleFunc("/user", handler.UserHandler)
|
||||||
mux.HandleFunc("/signup", handler.SignUpHandler)
|
mux.HandleFunc("/signup", handler.SignUpPageHandler)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/login", handler.LoginHandler)
|
||||||
|
mux.HandleFunc("POST /api/logout", handler.LogoutHandler)
|
||||||
|
mux.HandleFunc("POST /api/signup", handler.SignUpHandler)
|
||||||
|
|
||||||
// Apply middleware (CORS first, then logging)
|
// Apply middleware (CORS first, then logging)
|
||||||
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))
|
muxMiddleware := handlers.LoggingMiddleware(handlers.CORSMiddleware(mux))
|
||||||
|
|
|
||||||
|
|
@ -37,20 +37,3 @@ func HashSecret(secret string) []byte {
|
||||||
func CheckExpiration(expirarion time.Time) bool {
|
func CheckExpiration(expirarion time.Time) bool {
|
||||||
return time.Since(expirarion).Seconds() >= sessionExpiresInSeconds
|
return time.Since(expirarion).Seconds() >= sessionExpiresInSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseCookies(cookieHeader string) map[string]string {
|
|
||||||
cookies := make(map[string]string)
|
|
||||||
if cookieHeader == "" {
|
|
||||||
return cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
pairs := strings.Split(cookieHeader, ";")
|
|
||||||
for _, pair := range pairs {
|
|
||||||
pair = strings.TrimSpace(pair)
|
|
||||||
parts := strings.SplitN(pair, "=", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
cookies[parts[0]] = parts[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cookies
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"go-backend/internal/database/sqlc"
|
"go-backend/internal/database/sqlc"
|
||||||
"go-backend/internal/services"
|
"go-backend/internal/services"
|
||||||
|
"log"
|
||||||
|
"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
|
||||||
|
|
@ -19,6 +22,7 @@ type UserService interface {
|
||||||
type AuthService interface {
|
type AuthService interface {
|
||||||
CreateSession(ctx context.Context, userID string) (*services.SessionWithToken, error)
|
CreateSession(ctx context.Context, userID string) (*services.SessionWithToken, error)
|
||||||
ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, error)
|
ValidateSessionToken(ctx context.Context, token string) (*sqlc.UserSession, error)
|
||||||
|
DeleteSession(ctx context.Context, token string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
|
|
@ -32,3 +36,117 @@ func NewHandler(users UserService, auth AuthService) *Handler {
|
||||||
auth: auth,
|
auth: auth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HomeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err.Error())
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
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", nil)
|
||||||
|
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) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
|
||||||
|
if cookie != nil {
|
||||||
|
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err.Error())
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session != nil {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SignUpPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
|
||||||
|
if cookie != nil {
|
||||||
|
session, err := h.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err.Error())
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session != nil {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []string{
|
||||||
|
"./ui/html/base.tmpl",
|
||||||
|
"./ui/html/pages/signup.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
@ -9,23 +8,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string
|
||||||
Password string `json:"password"`
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req LoginRequest
|
req := LoginRequest{
|
||||||
|
Username: r.PostFormValue("username"),
|
||||||
decoder := json.NewDecoder(r.Body)
|
Password: r.PostFormValue("password"),
|
||||||
err := decoder.Decode(&req)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
|
|
@ -67,6 +62,38 @@ func (h *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("Success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.auth.DeleteSession(r.Context(), cookie.Value)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie = &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: "",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("Success"))
|
w.Write([]byte("Success"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go-backend/internal/auth"
|
|
||||||
"go-backend/internal/database"
|
|
||||||
"go-backend/internal/services"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeUsers and fakeAuth satisfy the UserService and AuthService interfaces
|
|
||||||
// declared in handler.go. No database, no network, no real hashing work —
|
|
||||||
// each test controls exactly what the "service layer" returns.
|
|
||||||
|
|
||||||
type fakeUsers struct {
|
|
||||||
user *database.User
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUsers) FindByUsername(username string) (*database.User, error) {
|
|
||||||
return f.user, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeUsers) FindByID(id string) (*database.User, error) {
|
|
||||||
return f.user, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeAuth struct {
|
|
||||||
session *services.SessionWithToken
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeAuth) CreateSession(userID string) (*auth.SessionWithToken, error) {
|
|
||||||
return f.session, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeAuth) ValidateSessionToken(token string) (*auth.Session, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashPassword builds a real Argon2id hash in the format auth.VerifyPassword
|
|
||||||
// expects, so the "correct password" test case exercises the real
|
|
||||||
// verification logic rather than a stub.
|
|
||||||
func hashPassword(password, salt string) string {
|
|
||||||
saltBytes := []byte(salt)
|
|
||||||
hash := argon2.IDKey([]byte(password), saltBytes, 3, 65536, 4, 32)
|
|
||||||
return "$argon2id$v=19$m=65536,t=3,p=4$" +
|
|
||||||
base64.RawStdEncoding.EncodeToString(saltBytes) + "$" +
|
|
||||||
base64.RawStdEncoding.EncodeToString(hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginHandler(t *testing.T) {
|
|
||||||
validUser := &database.User{ID: "user-1", Username: "alice", PasswordHash: hashPassword("correct-horse", "somesalt16bytes!")}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
users UserService
|
|
||||||
auth AuthService
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "successful login sets session cookie",
|
|
||||||
body: `{"username":"alice","password":"correct-horse"}`,
|
|
||||||
users: &fakeUsers{user: validUser},
|
|
||||||
auth: &fakeAuth{session: &services.SessionWithToken{Token: "abc.def"}},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong password rejected",
|
|
||||||
body: `{"username":"alice","password":"wrong-password"}`,
|
|
||||||
users: &fakeUsers{user: validUser},
|
|
||||||
auth: &fakeAuth{},
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown username rejected",
|
|
||||||
body: `{"username":"nobody","password":"whatever"}`,
|
|
||||||
users: &fakeUsers{user: nil},
|
|
||||||
auth: &fakeAuth{},
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing fields rejected before hitting services",
|
|
||||||
body: `{"username":"","password":""}`,
|
|
||||||
users: &fakeUsers{err: errors.New("should never be called")},
|
|
||||||
auth: &fakeAuth{},
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "user lookup failure returns 500",
|
|
||||||
body: `{"username":"alice","password":"correct-horse"}`,
|
|
||||||
users: &fakeUsers{err: errors.New("connection refused")},
|
|
||||||
auth: &fakeAuth{},
|
|
||||||
wantStatus: http.StatusInternalServerError,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "session creation failure returns 500",
|
|
||||||
body: `{"username":"alice","password":"correct-horse"}`,
|
|
||||||
users: &fakeUsers{user: validUser},
|
|
||||||
auth: &fakeAuth{err: errors.New("disk full")},
|
|
||||||
wantStatus: http.StatusInternalServerError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
h := NewHandler(tt.users, tt.auth)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(tt.body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.LoginHandler(w, req)
|
|
||||||
|
|
||||||
if w.Code != tt.wantStatus {
|
|
||||||
t.Fatalf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.wantStatus == http.StatusOK {
|
|
||||||
resp := w.Result()
|
|
||||||
cookies := resp.Cookies()
|
|
||||||
if len(cookies) != 1 || cookies[0].Name != "session" || cookies[0].Value != "abc.def" {
|
|
||||||
t.Fatalf("expected session cookie with token abc.def, got %+v", cookies)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginHandlerRejectsInvalidJSON(t *testing.T) {
|
|
||||||
h := NewHandler(&fakeUsers{}, &fakeAuth{})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBufferString(`not-json`))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.LoginHandler(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginHandlerRejectsWrongMethod(t *testing.T) {
|
|
||||||
h := NewHandler(&fakeUsers{}, &fakeAuth{})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.LoginHandler(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusMethodNotAllowed {
|
|
||||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"go-backend/internal/auth"
|
"go-backend/internal/auth"
|
||||||
"go-backend/internal/database/sqlc"
|
"go-backend/internal/database/sqlc"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SignUpRequest struct {
|
type SignupRequest struct {
|
||||||
Username string `json:"username"`
|
Username string
|
||||||
Password string `json:"password"`
|
Password string
|
||||||
Email string `json:"email"`
|
Email string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -20,13 +19,10 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req SignUpRequest
|
req := SignupRequest{
|
||||||
|
Username: r.PostFormValue("username"),
|
||||||
decoder := json.NewDecoder(r.Body)
|
Password: r.PostFormValue("password"),
|
||||||
err := decoder.Decode(&req)
|
Email: r.PostFormValue("email"),
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" || req.Email == "" {
|
if req.Username == "" || req.Password == "" || req.Email == "" {
|
||||||
|
|
@ -78,6 +74,7 @@ func (h *Handler) SignUpHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("Success"))
|
w.Write([]byte("Success"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"go-backend/internal/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
|
|
@ -20,15 +18,14 @@ func (h *Handler) UserHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session token from cookie
|
// Get session token from cookie
|
||||||
cookies := auth.ParseCookies(r.Header.Get("Cookie"))
|
cookie, err := r.Cookie("session")
|
||||||
sessionToken, exists := cookies["session"]
|
if err != nil {
|
||||||
if !exists {
|
|
||||||
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized, no session token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session
|
// Validate session
|
||||||
session, err := h.auth.ValidateSessionToken(r.Context(), sessionToken)
|
session, 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
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,16 @@ func (s *AuthService) ValidateSessionToken(ctx context.Context, token string) (*
|
||||||
|
|
||||||
return &session, nil
|
return &session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
tokenParts := strings.Split(token, ".")
|
||||||
|
if len(tokenParts) != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := tokenParts[0]
|
||||||
|
|
||||||
|
err := s.queries.DeleteSessionById(ctx, sessionID)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
28
ui/html/base.tmpl
Normal file
28
ui/html/base.tmpl
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{{define "base"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>{{template "title" .}} - Golang template</title>
|
||||||
|
<!-- Link to the CSS stylesheet and favicon -->
|
||||||
|
<link rel="stylesheet" href="/static/css/main.css" />
|
||||||
|
<link
|
||||||
|
rel="shortcut icon"
|
||||||
|
href="/static/img/favicon.ico"
|
||||||
|
type="image/x-icon"
|
||||||
|
/>
|
||||||
|
<!-- Also link to some fonts hosted by Google -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700"
|
||||||
|
/>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>{{template "main" .}}</main>
|
||||||
|
<footer>Powered by <a href="https://go.dev/">Go</a></footer>
|
||||||
|
<!-- And include the JavaScript file -->
|
||||||
|
<script src="/static/js/main.js" type="text/javascript"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
6
ui/html/pages/index.tmpl
Normal file
6
ui/html/pages/index.tmpl
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{{define "title"}}Home{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<button hx-post="/api/logout" class="btn">Logout</button>
|
||||||
|
{{end}}
|
||||||
38
ui/html/pages/login.tmpl
Normal file
38
ui/html/pages/login.tmpl
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{{define "title"}}Welcome{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="container">
|
||||||
|
<h1>Log in</h1>
|
||||||
|
|
||||||
|
<form hx-post="/api/login" hx-target="#error-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<div id="error-container" style="color: red; margin-bottom: 10px"></div>
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin-top: 20px">
|
||||||
|
Don't have an account? <a href="/signup">Sign up here</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
52
ui/html/pages/signup.tmpl
Normal file
52
ui/html/pages/signup.tmpl
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{{define "title"}}Welcome{{end}}
|
||||||
|
|
||||||
|
{{define "main"}}
|
||||||
|
<div class="container">
|
||||||
|
<h1>Register</h1>
|
||||||
|
|
||||||
|
<form hx-post="/api/signup" hx-target="#error-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<div
|
||||||
|
id="error-container"
|
||||||
|
style="color: red; margin-bottom: 10px"
|
||||||
|
></div>
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Register</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin-top: 20px">
|
||||||
|
Already have an account? <a href="/login">Login here</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
10
ui/static/js/main.js
Normal file
10
ui/static/js/main.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// By default htmx only swaps 2xx/304 responses into hx-target; 4xx/5xx
|
||||||
|
// responses are treated as errors and left unswapped. Our API handlers
|
||||||
|
// return error status codes with a plain-text body meant for the
|
||||||
|
// error-container div, so force the swap to happen anyway.
|
||||||
|
document.body.addEventListener("htmx:beforeSwap", function (evt) {
|
||||||
|
if (evt.detail.xhr.status >= 400) {
|
||||||
|
evt.detail.shouldSwap = true;
|
||||||
|
evt.detail.isError = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue