simple auth example
This commit is contained in:
parent
13f650b28b
commit
19638326dd
42 changed files with 1185 additions and 0 deletions
11
examples/simple-auth/.dockerignore
Normal file
11
examples/simple-auth/.dockerignore
Normal 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
6
examples/simple-auth/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/assets/dist
|
||||
tmp
|
||||
node_modules
|
||||
.idea
|
||||
__htmgo
|
||||
dist
|
||||
38
examples/simple-auth/Dockerfile
Normal file
38
examples/simple-auth/Dockerfile
Normal 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"]
|
||||
20
examples/simple-auth/Taskfile.yml
Normal file
20
examples/simple-auth/Taskfile.yml
Normal 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
|
||||
13
examples/simple-auth/assets.go
Normal file
13
examples/simple-auth/assets.go
Normal 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()
|
||||
}
|
||||
3
examples/simple-auth/assets/css/input.css
Normal file
3
examples/simple-auth/assets/css/input.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
BIN
examples/simple-auth/assets/public/apple-touch-icon.png
Normal file
BIN
examples/simple-auth/assets/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
examples/simple-auth/assets/public/favicon.ico
Normal file
BIN
examples/simple-auth/assets/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
examples/simple-auth/assets/public/icon-192-maskable.png
Normal file
BIN
examples/simple-auth/assets/public/icon-192-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/simple-auth/assets/public/icon-192.png
Normal file
BIN
examples/simple-auth/assets/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
examples/simple-auth/assets/public/icon-512-maskable.png
Normal file
BIN
examples/simple-auth/assets/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
examples/simple-auth/assets/public/icon-512.png
Normal file
BIN
examples/simple-auth/assets/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
16
examples/simple-auth/assets_prod.go
Normal file
16
examples/simple-auth/assets_prod.go
Normal 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
|
||||
}
|
||||
11
examples/simple-auth/go.mod
Normal file
11
examples/simple-auth/go.mod
Normal 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
|
||||
)
|
||||
18
examples/simple-auth/go.sum
Normal file
18
examples/simple-auth/go.sum
Normal 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=
|
||||
BIN
examples/simple-auth/htmgo-user-example.db
Normal file
BIN
examples/simple-auth/htmgo-user-example.db
Normal file
Binary file not shown.
10
examples/simple-auth/htmgo.yml
Normal file
10
examples/simple-auth/htmgo.yml
Normal 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"]
|
||||
31
examples/simple-auth/internal/db/db.go
Normal file
31
examples/simple-auth/internal/db/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
26
examples/simple-auth/internal/db/models.go
Normal file
26
examples/simple-auth/internal/db/models.go
Normal 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
|
||||
}
|
||||
25
examples/simple-auth/internal/db/provider.go
Normal file
25
examples/simple-auth/internal/db/provider.go
Normal 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)
|
||||
}
|
||||
31
examples/simple-auth/internal/db/queries.sql
Normal file
31
examples/simple-auth/internal/db/queries.sql
Normal 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 = ?;
|
||||
123
examples/simple-auth/internal/db/queries.sql.go
Normal file
123
examples/simple-auth/internal/db/queries.sql.go
Normal 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
|
||||
}
|
||||
28
examples/simple-auth/internal/db/schema.sql
Normal file
28
examples/simple-auth/internal/db/schema.sql
Normal 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);
|
||||
17
examples/simple-auth/internal/embedded/os.go
Normal file
17
examples/simple-auth/internal/embedded/os.go
Normal 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{}
|
||||
}
|
||||
118
examples/simple-auth/internal/user/handler.go
Normal file
118
examples/simple-auth/internal/user/handler.go
Normal 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
|
||||
}
|
||||
18
examples/simple-auth/internal/user/http.go
Normal file
18
examples/simple-auth/internal/user/http.go
Normal 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
|
||||
}
|
||||
18
examples/simple-auth/internal/user/password.go
Normal file
18
examples/simple-auth/internal/user/password.go
Normal 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
|
||||
}
|
||||
83
examples/simple-auth/internal/user/session.go
Normal file
83
examples/simple-auth/internal/user/session.go
Normal 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
|
||||
}
|
||||
35
examples/simple-auth/main.go
Normal file
35
examples/simple-auth/main.go
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
74
examples/simple-auth/pages/index.go
Normal file
74
examples/simple-auth/pages/index.go
Normal 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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
49
examples/simple-auth/pages/login.go
Normal file
49
examples/simple-auth/pages/login.go
Normal 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"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
23
examples/simple-auth/pages/logout.go
Normal file
23
examples/simple-auth/pages/logout.go
Normal 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
|
||||
}
|
||||
49
examples/simple-auth/pages/register.go
Normal file
49
examples/simple-auth/pages/register.go
Normal 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"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
32
examples/simple-auth/pages/root.go
Normal file
32
examples/simple-auth/pages/root.go
Normal 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...),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
36
examples/simple-auth/partials/profile.go
Normal file
36
examples/simple-auth/partials/profile.go
Normal 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("/")
|
||||
}
|
||||
62
examples/simple-auth/partials/user.go
Normal file
62
examples/simple-auth/partials/user.go
Normal 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("/")
|
||||
}
|
||||
9
examples/simple-auth/sqlc.yaml
Normal file
9
examples/simple-auth/sqlc.yaml
Normal 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"
|
||||
5
examples/simple-auth/tailwind.config.js
Normal file
5
examples/simple-auth/tailwind.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["**/*.go"],
|
||||
plugins: [],
|
||||
};
|
||||
41
examples/simple-auth/ui/button.go
Normal file
41
examples/simple-auth/ui/button.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
17
examples/simple-auth/ui/error.go
Normal file
17
examples/simple-auth/ui/error.go
Normal 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),
|
||||
)
|
||||
}
|
||||
55
examples/simple-auth/ui/input.go
Normal file
55
examples/simple-auth/ui/input.go
Normal 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
|
||||
}
|
||||
34
examples/simple-auth/ui/login.go
Normal file
34
examples/simple-auth/ui/login.go
Normal 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue