simple auth example

This commit is contained in:
maddalax 2024-10-20 07:48:58 -05:00
parent 13f650b28b
commit 19638326dd
42 changed files with 1185 additions and 0 deletions

View file

@ -0,0 +1,11 @@
# Project exclude paths
/tmp/
node_modules/
dist/
js/dist
js/node_modules
go.work
go.work.sum
.idea
!framework/assets/dist
__htmgo

6
examples/simple-auth/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/assets/dist
tmp
node_modules
.idea
__htmgo
dist

View file

@ -0,0 +1,38 @@
# Stage 1: Build the Go binary
FROM golang:1.23-alpine AS builder
RUN apk update
RUN apk add git
RUN apk add curl
# Set the working directory inside the container
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download and cache the Go modules
RUN go mod download
# Copy the source code into the container
COPY . .
# Build the Go binary for Linux
RUN GOPRIVATE=github.com/maddalax GOPROXY=direct go run github.com/maddalax/htmgo/cli/htmgo@latest build
# Stage 2: Create the smallest possible image
FROM gcr.io/distroless/base-debian11
# Set the working directory inside the container
WORKDIR /app
# Copy the Go binary from the builder stage
COPY --from=builder /app/dist .
# Expose the necessary port (replace with your server port)
EXPOSE 3000
# Command to run the binary
CMD ["./simple-auth"]

View file

@ -0,0 +1,20 @@
version: '3'
tasks:
run:
cmds:
- htmgo run
silent: true
build:
cmds:
- htmgo build
docker:
cmds:
- docker build .
watch:
cmds:
- htmgo watch
silent: true

View file

@ -0,0 +1,13 @@
//go:build !prod
// +build !prod
package main
import (
"io/fs"
"simpleauth/internal/embedded"
)
func GetStaticAssets() fs.FS {
return embedded.NewOsFs()
}

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,16 @@
//go:build prod
// +build prod
package main
import (
"embed"
"io/fs"
)
//go:embed assets/dist/*
var staticAssets embed.FS
func GetStaticAssets() fs.FS {
return staticAssets
}

View file

@ -0,0 +1,11 @@
module simpleauth
go 1.23.0
require github.com/maddalax/htmgo/framework v0.0.0-20241018222959-a7110576d234
require (
github.com/go-chi/chi/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
)

View file

@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/maddalax/htmgo/framework v0.0.0-20241018222959-a7110576d234 h1:1WfY9h8EoZXwzM8hmfCXolZVKr4/p1dgLoW9rKQ5Lso=
github.com/maddalax/htmgo/framework v0.0.0-20241018222959-a7110576d234/go.mod h1:NGGzWVXWksrQJ9kV9SGa/A1F1Bjsgc08cN7ZVb98RqY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Binary file not shown.

View file

@ -0,0 +1,10 @@
# htmgo configuration
# if tailwindcss is enabled, htmgo will automatically compile your tailwind and output it to assets/dist
tailwind: true
# which directories to ignore when watching for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_ignore: [".git", "node_modules", "dist/*"]
# files to watch for changes, supports glob patterns through https://github.com/bmatcuk/doublestar
watch_files: ["**/*.go", "**/*.css", "**/*.md"]

View file

@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

View file

@ -0,0 +1,26 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package db
import (
"database/sql"
)
type Session struct {
ID int64
UserID int64
SessionID string
CreatedAt sql.NullString
ExpiresAt string
}
type User struct {
ID int64
Email string
Password string
Metadata interface{}
CreatedAt sql.NullString
UpdatedAt sql.NullString
}

View file

@ -0,0 +1,25 @@
package db
import (
"context"
"database/sql"
_ "embed"
_ "github.com/mattn/go-sqlite3"
)
//go:embed schema.sql
var ddl string
func Provide() *Queries {
db, err := sql.Open("sqlite3", "file:htmgo-user-example.db?cache=shared&_fk=1")
if err != nil {
panic(err)
}
if _, err := db.ExecContext(context.Background(), ddl); err != nil {
panic(err)
}
return New(db)
}

View file

@ -0,0 +1,31 @@
-- Queries for User Management
-- name: CreateUser :one
INSERT INTO user (email, password, metadata)
VALUES (?, ?, ?)
RETURNING id;
-- name: CreateSession :exec
INSERT INTO sessions (user_id, session_id, expires_at)
VALUES (?, ?, ?);
-- name: GetUserByToken :one
SELECT u.*
FROM user u
JOIN sessions t ON u.id = t.user_id
WHERE t.session_id = ?
AND t.expires_at > datetime('now');
-- name: GetUserByID :one
SELECT *
FROM user
WHERE id = ?;
-- name: GetUserByEmail :one
SELECT *
FROM user
WHERE email = ?;
-- name: UpdateUserMetadata :exec
UPDATE user SET metadata = json_patch(COALESCE(metadata, '{}'), ?) WHERE id = ?;

View file

@ -0,0 +1,123 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: queries.sql
package db
import (
"context"
)
const createSession = `-- name: CreateSession :exec
INSERT INTO sessions (user_id, session_id, expires_at)
VALUES (?, ?, ?)
`
type CreateSessionParams struct {
UserID int64
SessionID string
ExpiresAt string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.ExecContext(ctx, createSession, arg.UserID, arg.SessionID, arg.ExpiresAt)
return err
}
const createUser = `-- name: CreateUser :one
INSERT INTO user (email, password, metadata)
VALUES (?, ?, ?)
RETURNING id
`
type CreateUserParams struct {
Email string
Password string
Metadata interface{}
}
// Queries for User Management
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.Password, arg.Metadata)
var id int64
err := row.Scan(&id)
return id, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password, metadata, created_at, updated_at
FROM user
WHERE email = ?
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Password,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password, metadata, created_at, updated_at
FROM user
WHERE id = ?
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Password,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByToken = `-- name: GetUserByToken :one
SELECT u.id, u.email, u.password, u.metadata, u.created_at, u.updated_at
FROM user u
JOIN sessions t ON u.id = t.user_id
WHERE t.session_id = ?
AND t.expires_at > datetime('now')
`
func (q *Queries) GetUserByToken(ctx context.Context, sessionID string) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByToken, sessionID)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.Password,
&i.Metadata,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateUserMetadata = `-- name: UpdateUserMetadata :exec
UPDATE user SET metadata = json_patch(COALESCE(metadata, '{}'), ?) WHERE id = ?
`
type UpdateUserMetadataParams struct {
JsonPatch interface{}
ID int64
}
func (q *Queries) UpdateUserMetadata(ctx context.Context, arg UpdateUserMetadataParams) error {
_, err := q.db.ExecContext(ctx, updateUserMetadata, arg.JsonPatch, arg.ID)
return err
}

View file

@ -0,0 +1,28 @@
-- SQLite schema for User Management
-- User table
CREATE TABLE IF NOT EXISTS user
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
metadata JSON DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Auth Token table
CREATE TABLE IF NOT EXISTS sessions
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
session_id TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
-- Indexes to improve query performance
CREATE INDEX IF NOT EXISTS idx_user_email ON user (email);
CREATE INDEX IF NOT EXISTS idx_session_id ON sessions (session_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON sessions (user_id);

View file

@ -0,0 +1,17 @@
package embedded
import (
"io/fs"
"os"
)
type OsFs struct {
}
func (receiver OsFs) Open(name string) (fs.File, error) {
return os.Open(name)
}
func NewOsFs() OsFs {
return OsFs{}
}

View file

@ -0,0 +1,118 @@
package user
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"simpleauth/internal/db"
)
type CreateUserRequest struct {
Email string
Password string
}
type LoginUserRequest struct {
Email string
Password string
}
type CreatedUser struct {
Id string
Email string
}
func Create(ctx *h.RequestContext, request CreateUserRequest) (int64, error) {
fmt.Printf("%+v\n", request)
if len(request.Password) < 6 {
return 0, errors.New("password must be at least 6 characters long")
}
queries := service.Get[db.Queries](ctx.ServiceLocator())
hashedPassword, err := HashPassword(request.Password)
if err != nil {
return 0, errors.New("something went wrong")
}
id, err := queries.CreateUser(context.Background(), db.CreateUserParams{
Email: request.Email,
Password: hashedPassword,
})
if err != nil {
if err.Error() == "UNIQUE constraint failed: user.email" {
return 0, errors.New("email already exists")
}
return 0, err
}
return id, nil
}
func Login(ctx *h.RequestContext, request LoginUserRequest) (int64, error) {
queries := service.Get[db.Queries](ctx.ServiceLocator())
user, err := queries.GetUserByEmail(context.Background(), request.Email)
if err != nil {
fmt.Printf("error: %s\n", err.Error())
return 0, errors.New("email or password is incorrect")
}
if !PasswordMatches(request.Password, user.Password) {
return 0, errors.New("email or password is incorrect")
}
session, err := CreateSession(ctx, user.ID)
if err != nil {
return 0, errors.New("something went wrong")
}
WriteSessionCookie(ctx, session)
return user.ID, nil
}
func ParseMeta(meta any) map[string]interface{} {
if meta == nil {
return map[string]interface{}{}
}
if m, ok := meta.(string); ok {
var dest map[string]interface{}
json.Unmarshal([]byte(m), &dest)
return dest
}
return meta.(map[string]interface{})
}
func GetMetaKey(meta map[string]interface{}, key string) string {
if val, ok := meta[key]; ok {
return val.(string)
}
return ""
}
func SetMeta(ctx *h.RequestContext, userId int64, meta map[string]interface{}) error {
queries := service.Get[db.Queries](ctx.ServiceLocator())
serialized, _ := json.Marshal(meta)
fmt.Printf("serialized: %s\n", string(serialized))
err := queries.UpdateUserMetadata(context.Background(), db.UpdateUserMetadataParams{
JsonPatch: serialized,
ID: userId,
})
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,18 @@
package user
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/internal/db"
)
func GetUserOrRedirect(ctx *h.RequestContext) (db.User, bool) {
user, err := GetUserFromSession(ctx)
if err != nil {
ctx.Response.Header().Set("Location", "/login")
ctx.Response.WriteHeader(302)
return db.User{}, false
}
return user, true
}

View file

@ -0,0 +1,18 @@
package user
import (
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedPassword), nil
}
func PasswordMatches(password string, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}

View file

@ -0,0 +1,83 @@
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"net/http"
"simpleauth/internal/db"
"time"
)
type CreatedSession struct {
Id string
Expiration time.Time
UserId int64
}
func CreateSession(ctx *h.RequestContext, userId int64) (CreatedSession, error) {
sessionId, err := GenerateSessionID()
if err != nil {
return CreatedSession{}, err
}
// create a session in the database
queries := service.Get[db.Queries](ctx.ServiceLocator())
created := CreatedSession{
Id: sessionId,
Expiration: time.Now().Add(time.Hour * 24),
UserId: userId,
}
err = queries.CreateSession(context.Background(), db.CreateSessionParams{
UserID: created.UserId,
SessionID: created.Id,
ExpiresAt: created.Expiration.Format(time.RFC3339),
})
if err != nil {
return CreatedSession{}, err
}
return created, nil
}
func GetUserFromSession(ctx *h.RequestContext) (db.User, error) {
cookie, err := ctx.Request.Cookie("session_id")
if err != nil {
return db.User{}, err
}
queries := service.Get[db.Queries](ctx.ServiceLocator())
user, err := queries.GetUserByToken(context.Background(), cookie.Value)
if err != nil {
return db.User{}, err
}
return user, nil
}
func WriteSessionCookie(ctx *h.RequestContext, session CreatedSession) {
cookie := http.Cookie{
Name: "session_id",
Value: session.Id,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: session.Expiration,
Path: "/",
}
ctx.Response.Header().Add("Set-Cookie", cookie.String())
}
func GenerateSessionID() (string, error) {
// Create a byte slice for storing the random bytes
bytes := make([]byte, 32) // 32 bytes = 256 bits, which is a secure length
// Read random bytes from crypto/rand
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Encode to hexadecimal to get a string representation
return hex.EncodeToString(bytes), nil
}

View file

@ -0,0 +1,35 @@
package main
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"io/fs"
"net/http"
"simpleauth/__htmgo"
"simpleauth/internal/db"
)
func main() {
locator := service.NewLocator()
service.Set(locator, service.Singleton, func() *db.Queries {
return db.Provide()
})
h.Start(h.AppOpts{
ServiceLocator: locator,
LiveReload: true,
Register: func(app *h.App) {
sub, err := fs.Sub(GetStaticAssets(), "assets/dist")
if err != nil {
panic(err)
}
http.FileServerFS(sub)
app.Router.Handle("/public/*", http.StripPrefix("/public", http.FileServerFS(sub)))
__htmgo.Register(app.Router)
},
})
}

View file

@ -0,0 +1,74 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/internal/db"
"simpleauth/internal/user"
"simpleauth/partials"
"simpleauth/ui"
)
func IndexPage(ctx *h.RequestContext) *h.Page {
u, ok := user.GetUserOrRedirect(ctx)
if !ok {
return nil
}
return h.NewPage(
RootPage(UserProfilePage(u)),
)
}
func UserProfilePage(u db.User) *h.Element {
meta := user.ParseMeta(u.Metadata)
return h.Div(
h.Class("flex flex-col gap-6 items-center pt-10 min-h-screen bg-neutral-100"),
h.H3F("User Profile", h.Class("text-2xl font-bold")),
h.Pf("Welcome, %s!", u.Email),
h.Form(
h.Attribute("hx-swap", "none"),
h.PostPartial(partials.UpdateProfile),
h.TriggerChildren(),
h.Class("flex flex-col gap-4 w-full max-w-md p-6 bg-white rounded-md shadow-md"),
ui.Input(ui.InputProps{
Id: "email",
Name: "email",
Label: "Email Address",
Type: "email",
DefaultValue: u.Email,
Children: []h.Ren{
h.Disabled(),
},
}),
ui.Input(ui.InputProps{
Name: "birth-date",
Label: "Birth Date",
DefaultValue: user.GetMetaKey(meta, "birthDate"),
Type: "date",
}),
ui.Input(ui.InputProps{
Name: "favorite-color",
Label: "Favorite Color",
DefaultValue: user.GetMetaKey(meta, "favoriteColor"),
}),
ui.Input(ui.InputProps{
Name: "occupation",
Label: "Occupation",
DefaultValue: user.GetMetaKey(meta, "occupation"),
}),
ui.FormError(""),
ui.SubmitButton("Save Changes"),
),
h.A(
h.Text("Log out"),
h.Href("/logout"),
h.Class("text-blue-400"),
),
)
}

View file

@ -0,0 +1,49 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/partials"
"simpleauth/ui"
)
func Login(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
ui.CenteredForm(ui.CenteredFormProps{
Title: "Sign In",
SubmitText: "Sign In",
PostUrl: h.GetPartialPath(partials.LoginUser),
Children: []h.Ren{
ui.Input(ui.InputProps{
Id: "username",
Name: "email",
Label: "Email Address",
Type: "email",
Required: true,
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(50),
},
}),
ui.Input(ui.InputProps{
Id: "password",
Name: "password",
Label: "Password",
Type: "password",
Required: true,
Children: []h.Ren{
h.MinLength(6),
},
}),
h.A(
h.Href("/register"),
h.Text("Don't have an account? Register here"),
h.Class("text-blue-500"),
),
},
}),
),
)
}

View file

@ -0,0 +1,23 @@
package pages
import "github.com/maddalax/htmgo/framework/h"
func LogoutPage(ctx *h.RequestContext) *h.Page {
// clear the session cookie
ctx.Response.Header().Set(
"Set-Cookie",
"session_id=; Path=/; Max-Age=0",
)
ctx.Response.Header().Set(
"Location",
"/login",
)
ctx.Response.WriteHeader(
302,
)
return nil
}

View file

@ -0,0 +1,49 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/partials"
"simpleauth/ui"
)
func Register(ctx *h.RequestContext) *h.Page {
return h.NewPage(
RootPage(
ui.CenteredForm(ui.CenteredFormProps{
PostUrl: h.GetPartialPath(partials.RegisterUser),
Title: "Create an Account",
SubmitText: "Register",
Children: []h.Ren{
ui.Input(ui.InputProps{
Id: "username",
Name: "email",
Label: "Email Address",
Type: "email",
Required: true,
Children: []h.Ren{
h.Attribute("autocomplete", "off"),
h.MaxLength(50),
},
}),
ui.Input(ui.InputProps{
Id: "password",
Name: "password",
Label: "Password",
Type: "password",
Required: true,
Children: []h.Ren{
h.MinLength(6),
},
}),
h.A(
h.Href("/login"),
h.Text("Already have an account? Login here"),
h.Class("text-blue-500"),
),
},
}),
),
)
}

View file

@ -0,0 +1,32 @@
package pages
import (
"github.com/maddalax/htmgo/framework/h"
)
func RootPage(children ...h.Ren) h.Ren {
return h.Html(
h.HxExtensions(h.BaseExtensions()),
h.Head(
h.Meta("viewport", "width=device-width, initial-scale=1"),
h.Link("/public/favicon.ico", "icon"),
h.Link("/public/apple-touch-icon.png", "apple-touch-icon"),
h.Meta("title", "htmgo template"),
h.Meta("charset", "utf-8"),
h.Meta("author", "htmgo"),
h.Meta("description", "this is a template"),
h.Meta("og:title", "htmgo template"),
h.Meta("og:url", "https://htmgo.dev"),
h.Link("canonical", "https://htmgo.dev"),
h.Meta("og:description", "this is a template"),
h.Link("/public/main.css", "stylesheet"),
h.Script("/public/htmgo.js"),
),
h.Body(
h.Div(
h.Class("flex flex-col gap-2 bg-white h-full"),
h.Fragment(children...),
),
),
)
}

View file

@ -0,0 +1,36 @@
package partials
import (
"github.com/maddalax/htmgo/framework/h"
"log/slog"
"simpleauth/internal/user"
"simpleauth/ui"
)
func UpdateProfile(ctx *h.RequestContext) *h.Partial {
if !ctx.IsHttpPost() {
return nil
}
patch := map[string]any{
"birthDate": ctx.FormValue("birth-date"),
"favoriteColor": ctx.FormValue("favorite-color"),
"occupation": ctx.FormValue("occupation"),
}
u, ok := user.GetUserOrRedirect(ctx)
if !ok {
return nil
}
err := user.SetMeta(ctx, u.ID, patch)
if err != nil {
slog.Error("failed to update user profile", slog.String("error", err.Error()))
ctx.Response.WriteHeader(400)
return ui.SwapFormError(ctx, "something went wrong")
}
return h.RedirectPartial("/")
}

View file

@ -0,0 +1,62 @@
package partials
import (
"github.com/maddalax/htmgo/framework/h"
"simpleauth/internal/user"
"simpleauth/ui"
)
func RegisterUser(ctx *h.RequestContext) *h.Partial {
if !ctx.IsHttpPost() {
return nil
}
payload := user.CreateUserRequest{
Email: ctx.FormValue("email"),
Password: ctx.FormValue("password"),
}
id, err := user.Create(
ctx,
payload,
)
if err != nil {
ctx.Response.WriteHeader(400)
return ui.SwapFormError(ctx, err.Error())
}
session, err := user.CreateSession(ctx, id)
if err != nil {
ctx.Response.WriteHeader(500)
return ui.SwapFormError(ctx, "something went wrong")
}
user.WriteSessionCookie(ctx, session)
return h.RedirectPartial("/")
}
func LoginUser(ctx *h.RequestContext) *h.Partial {
if !ctx.IsHttpPost() {
return nil
}
payload := user.LoginUserRequest{
Email: ctx.FormValue("email"),
Password: ctx.FormValue("password"),
}
_, err := user.Login(
ctx,
payload,
)
if err != nil {
ctx.Response.WriteHeader(400)
return ui.SwapFormError(ctx, err.Error())
}
return h.RedirectPartial("/")
}

View file

@ -0,0 +1,9 @@
version: "2"
sql:
- schema: "internal/db/schema.sql"
queries: "internal/db/queries.sql"
engine: "sqlite"
gen:
go:
package: "db"
out: "internal/db"

View file

@ -0,0 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["**/*.go"],
plugins: [],
};

View file

@ -0,0 +1,41 @@
package ui
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/js"
)
func SubmitButton(submitText string) *h.Element {
buttonClasses := "rounded items-center px-3 py-2 bg-slate-800 text-white w-full text-center"
return h.Div(
h.HxBeforeRequest(
js.RemoveClassOnChildren(".loading", "hidden"),
js.SetClassOnChildren(".submit", "hidden"),
),
h.HxAfterRequest(
js.SetClassOnChildren(".loading", "hidden"),
js.RemoveClassOnChildren(".submit", "hidden"),
),
h.Class("flex gap-2 justify-center"),
h.Button(
h.Class("loading hidden relative text-center", buttonClasses),
spinner(),
h.Disabled(),
h.Text("Submitting..."),
),
h.Button(
h.Type("submit"),
h.Class("submit", buttonClasses),
h.Text(submitText),
),
)
}
func spinner(children ...h.Ren) *h.Element {
return h.Div(
h.Children(children...),
h.Class("absolute left-1 spinner spinner-border animate-spin inline-block w-6 h-6 border-4 rounded-full border-slate-200 border-t-transparent"),
h.Attribute("role", "status"),
)
}

View file

@ -0,0 +1,17 @@
package ui
import "github.com/maddalax/htmgo/framework/h"
func FormError(error string) *h.Element {
return h.Div(
h.Id("form-error"),
h.Text(error),
h.If(error != "", h.Class("p-4 bg-rose-400 text-white rounded")),
)
}
func SwapFormError(ctx *h.RequestContext, error string) *h.Partial {
return h.SwapPartial(ctx,
FormError(error),
)
}

View file

@ -0,0 +1,55 @@
package ui
import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/hx"
)
type InputProps struct {
Id string
Label string
Name string
Type string
DefaultValue string
Placeholder string
Required bool
ValidationPath string
Error string
Children []h.Ren
}
func Input(props InputProps) *h.Element {
validation := h.If(props.ValidationPath != "", h.Children(
h.Post(props.ValidationPath, hx.BlurEvent),
h.Attribute("hx-swap", "innerHTML transition:true"),
h.Attribute("hx-target", "next div"),
))
if props.Type == "" {
props.Type = "text"
}
input := h.Input(
props.Type,
h.Class("border p-2 rounded focus:outline-none focus:ring focus:ring-slate-800"),
h.If(props.Name != "", h.Name(props.Name)),
h.If(props.Children != nil, h.Children(props.Children...)),
h.If(props.Required, h.Required()),
h.If(props.Placeholder != "", h.Placeholder(props.Placeholder)),
h.If(props.DefaultValue != "", h.Attribute("value", props.DefaultValue)),
validation,
)
wrapped := h.Div(
h.If(props.Id != "", h.Id(props.Id)),
h.Class("flex flex-col gap-1"),
h.If(props.Label != "", h.Label(h.Text(props.Label))),
input,
h.Div(
h.Id(props.Id+"-error"),
h.Class("text-red-500"),
),
)
return wrapped
}

View file

@ -0,0 +1,34 @@
package ui
import (
"chat/components"
"github.com/maddalax/htmgo/framework/h"
)
type CenteredFormProps struct {
Title string
Children []h.Ren
SubmitText string
PostUrl string
}
func CenteredForm(props CenteredFormProps) *h.Element {
return h.Div(
h.Class("flex flex-col items-center justify-center min-h-screen bg-neutral-100"),
h.Div(
h.Class("bg-white p-8 rounded-lg shadow-lg w-full max-w-md"),
h.H2F(props.Title, h.Class("text-3xl font-bold text-center mb-6")),
h.Form(
h.TriggerChildren(),
h.Post(props.PostUrl),
h.Attribute("hx-swap", "none"),
h.Class("flex flex-col gap-4"),
h.Children(props.Children...),
// Error message
components.FormError(""),
// Submit button at the bottom
SubmitButton(props.SubmitText),
),
),
)
}