feat(monitor): infra scaffold — Dockerfiles, conf/ tree, .gitignore

This commit is contained in:
CarterPerez-dev 2026-05-01 06:12:25 -04:00
parent 24c484386b
commit 470533fd66
102 changed files with 13846 additions and 0 deletions

View File

@ -0,0 +1,18 @@
# ©AngelaMos | 2026
# .gitignore
.env
.env.local
.env.*.local
backend/tmp/
backend/bin/
backend/coverage.out
backend/coverage.html
backend/keys/
frontend/dist/
frontend/node_modules/
frontend/.vite/
docs/

View File

@ -0,0 +1,29 @@
# AngelaMos | 2026
# .air.toml - Hot reload configuration
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/api"
bin = "tmp/main"
full_bin = "./tmp/main"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"]
exclude_regex = ["_test\\.go"]
delay = 1000
stop_on_error = true
send_interrupt = true
kill_delay = 500
[log]
time = false
[color]
main = "cyan"
watcher = "magenta"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

View File

@ -0,0 +1,33 @@
# AngelaMos | 2026
# .env.example
# Environment: development, staging, production
ENVIRONMENT=development
# Server
HOST=0.0.0.0
PORT=8080
# Database
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app?sslmode=disable
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
JWT_PRIVATE_KEY_PATH=keys/private.pem
JWT_PUBLIC_KEY_PATH=keys/public.pem
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# Rate Limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=1m
# OpenTelemetry
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_SERVICE_NAME=go-backend
# Logging
LOG_LEVEL=debug
LOG_FORMAT=text

View File

@ -0,0 +1,44 @@
# AngelaMos | 2026
# .gitignore
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test
*.test
coverage.out
coverage.html
# Build
tmp/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Keys (sensitive)
keys/*.pem
keys/*.key
# Vendor (if using)
vendor/
# Debug
__debug_bin*

View File

@ -0,0 +1,113 @@
# AngelaMos | 2026
# .golangci.yml
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- gosec
- bodyclose
- nilerr
- errorlint
- exhaustive
- gocritic
- funlen
- gocognit
- dupl
- goconst
- ineffassign
- unused
- unconvert
- unparam
- testifylint
- fatcontext
settings:
errcheck:
check-type-assertions: true
check-blank: true
funlen:
lines: 100
statements: 50
gocognit:
min-complexity: 20
govet:
enable-all: true
disable:
- fieldalignment
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: increment-decrement
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unreachable-code
staticcheck:
checks:
- all
gosec:
excludes:
- G104
sloglint:
no-mixed-args: true
kv-only: true
context: all
issues:
max-same-issues: 50
exclude-dirs:
- vendor
- testdata
exclude-rules:
- path: _test\.go
linters:
- funlen
- dupl
- goconst
formatters:
enable:
- gci # Groups imports
- gofumpt # Whitespace
- golines # Vertical wrap
settings:
golines:
max-len: 80
reformat-tags: true
goimports:
local-prefixes:
- github.com/carterperez-dev/templates/go-backend
gci:
sections:
- standard
- default
- prefix(github.com/carterperez-dev)
custom-order: true
gofumpt:
extra-rules: true

View File

@ -0,0 +1,221 @@
// AngelaMos | 2026
// main.go
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/carterperez-dev/templates/go-backend/internal/admin"
"github.com/carterperez-dev/templates/go-backend/internal/auth"
"github.com/carterperez-dev/templates/go-backend/internal/config"
"github.com/carterperez-dev/templates/go-backend/internal/core"
"github.com/carterperez-dev/templates/go-backend/internal/health"
"github.com/carterperez-dev/templates/go-backend/internal/middleware"
"github.com/carterperez-dev/templates/go-backend/internal/server"
"github.com/carterperez-dev/templates/go-backend/internal/user"
)
const (
drainDelay = 5 * time.Second
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
flag.Parse()
if err := run(*configPath); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
}
}
//nolint:funlen // bootstrap code is inherently verbose
func run(configPath string) error {
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer stop()
cfg, err := config.Load(configPath)
if err != nil {
return err
}
logger := setupLogger(cfg.Log)
slog.SetDefault(logger)
logger.Info("starting application",
"name", cfg.App.Name,
"version", cfg.App.Version,
"environment", cfg.App.Environment,
)
var telemetry *core.Telemetry
if cfg.Otel.Enabled {
tel, telErr := core.NewTelemetry(ctx, cfg.Otel, cfg.App)
if telErr != nil {
logger.Warn("failed to initialize telemetry", "error", telErr)
} else {
telemetry = tel
logger.Info("OpenTelemetry tracer initialized",
"endpoint", cfg.Otel.Endpoint,
)
}
}
db, err := core.NewDatabase(ctx, cfg.Database)
if err != nil {
return err
}
logger.Info("database connected",
"max_open_conns", cfg.Database.MaxOpenConns,
"max_idle_conns", cfg.Database.MaxIdleConns,
)
redis, err := core.NewRedis(ctx, cfg.Redis)
if err != nil {
return err
}
logger.Info("redis connected",
"pool_size", cfg.Redis.PoolSize,
)
jwtManager, err := auth.NewJWTManager(cfg.JWT)
if err != nil {
return err
}
logger.Info("JWT manager initialized",
"algorithm", "ES256",
"key_id", jwtManager.GetKeyID(),
)
userRepo := user.NewRepository(db.DB)
userSvc := user.NewService(userRepo)
userHandler := user.NewHandler(userSvc)
authRepo := auth.NewRepository(db.DB)
authSvc := auth.NewService(authRepo, jwtManager, userSvc, redis.Client)
authHandler := auth.NewHandler(authSvc)
healthHandler := health.NewHandler(db, redis)
adminHandler := admin.NewHandler(admin.HandlerConfig{
DBStats: db.Stats,
RedisStats: redis.PoolStats,
DBPing: db.Ping,
RedisPing: redis.Ping,
})
srv := server.New(server.Config{
ServerConfig: cfg.Server,
HealthHandler: healthHandler,
Logger: logger,
})
router := srv.Router()
router.Use(middleware.RequestID)
router.Use(middleware.Logger(logger))
router.Use(
middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{
Limit: middleware.PerMinute(
cfg.RateLimit.Requests,
cfg.RateLimit.Burst,
),
FailOpen: true,
}).Handler,
)
router.Use(middleware.SecurityHeaders(cfg.App.Environment == "production"))
router.Use(middleware.CORS(cfg.CORS))
healthHandler.RegisterRoutes(router)
router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler())
authenticator := middleware.Authenticator(jwtManager)
adminOnly := middleware.RequireAdmin
router.Route("/v1", func(r chi.Router) {
authHandler.RegisterRoutes(r, authenticator)
r.Post("/users", authHandler.Register)
userHandler.RegisterRoutes(r, authenticator)
userHandler.RegisterAdminRoutes(r, authenticator, adminOnly)
adminHandler.RegisterRoutes(r, authenticator, adminOnly)
})
errChan := make(chan error, 1)
go func() {
errChan <- srv.Start()
}()
select {
case err := <-errChan:
return err
case <-ctx.Done():
logger.Info("shutdown signal received")
}
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
cfg.Server.ShutdownTimeout+drainDelay+5*time.Second,
)
defer cancel()
if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil {
logger.Error("server shutdown error", "error", err)
}
if telemetry != nil {
if err := telemetry.Shutdown(shutdownCtx); err != nil {
logger.Error("telemetry shutdown error", "error", err)
}
}
if err := redis.Close(); err != nil {
logger.Error("redis close error", "error", err)
}
if err := db.Close(); err != nil {
logger.Error("database close error", "error", err)
}
logger.Info("application stopped")
return nil
}
func setupLogger(cfg config.LogConfig) *slog.Logger {
var handler slog.Handler
level := slog.LevelInfo
switch cfg.Level {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
if cfg.Format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(handler)
}

View File

@ -0,0 +1,53 @@
# AngelaMos | 2026
# config.yaml - Default configuration
app:
name: "Go Backend Template"
version: "1.0.0"
server:
host: "0.0.0.0"
port: 8080
read_timeout: 30s
write_timeout: 30s
idle_timeout: 120s
shutdown_timeout: 15s
database:
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 1h
conn_max_idle_time: 30m
redis:
pool_size: 10
min_idle_conns: 5
jwt:
access_token_expire: 15m
refresh_token_expire: 168h
issuer: "go-backend"
rate_limit:
requests: 100
window: 1m
burst: 20
cors:
allowed_origins:
- "http://localhost:3000"
- "http://localhost:3420"
allowed_methods:
- "GET"
- "POST"
- "PUT"
- "PATCH"
- "DELETE"
- "OPTIONS"
allowed_headers:
- "Accept"
- "Authorization"
- "Content-Type"
- "X-Request-ID"
allow_credentials: true
max_age: 300

View File

@ -0,0 +1,69 @@
module github.com/carterperez-dev/templates/go-backend
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.23.0
github.com/go-redis/redis_rate/v10 v10.0.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/jmoiron/sqlx v1.4.0
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.1.2
github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/redis/go-redis/v9 v9.7.0
go.opentelemetry.io/otel v1.33.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
go.opentelemetry.io/otel/sdk v1.33.0
go.opentelemetry.io/otel/trace v1.33.0
golang.org/x/crypto v0.43.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.68.1
)
require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/protobuf v1.35.2 // indirect
)

View File

@ -0,0 +1,165 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ=
github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,208 @@
// AngelaMos | 2026
// handler.go
package admin
import (
"context"
"database/sql"
"net/http"
"runtime"
"github.com/go-chi/chi/v5"
"github.com/redis/go-redis/v9"
"github.com/carterperez-dev/templates/go-backend/internal/core"
)
type AuthService interface {
InvalidateAllSessions(ctx context.Context) error
}
type Handler struct {
dbStats func() sql.DBStats
redisStats func() *redis.PoolStats
redisPing func(ctx context.Context) error
dbPing func(ctx context.Context) error
authSvc AuthService
}
type HandlerConfig struct {
DBStats func() sql.DBStats
RedisStats func() *redis.PoolStats
RedisPing func(ctx context.Context) error
DBPing func(ctx context.Context) error
AuthSvc AuthService
}
func NewHandler(cfg HandlerConfig) *Handler {
return &Handler{
dbStats: cfg.DBStats,
redisStats: cfg.RedisStats,
redisPing: cfg.RedisPing,
dbPing: cfg.DBPing,
authSvc: cfg.AuthSvc,
}
}
func (h *Handler) RegisterRoutes(
r chi.Router,
authenticator, adminOnly func(http.Handler) http.Handler,
) {
r.Route("/admin", func(r chi.Router) {
r.Use(authenticator)
r.Use(adminOnly)
r.Get("/stats", h.GetSystemStats)
r.Get("/stats/db", h.GetDatabaseStats)
r.Get("/stats/redis", h.GetRedisStats)
r.Get("/stats/runtime", h.GetRuntimeStats)
})
}
func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
dbHealthy := true
if h.dbPing != nil {
if err := h.dbPing(ctx); err != nil {
dbHealthy = false
}
}
redisHealthy := true
if h.redisPing != nil {
if err := h.redisPing(ctx); err != nil {
redisHealthy = false
}
}
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
response := SystemStatsResponse{
Database: DatabaseStatus{
Healthy: dbHealthy,
Stats: h.getDBStats(),
},
Redis: RedisStatus{
Healthy: redisHealthy,
Stats: h.getRedisStats(),
},
Runtime: RuntimeStats{
GoVersion: runtime.Version(),
NumGoroutine: runtime.NumGoroutine(),
NumCPU: runtime.NumCPU(),
MemAlloc: memStats.Alloc,
MemSys: memStats.Sys,
NumGC: memStats.NumGC,
},
}
core.OK(w, response)
}
func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) {
core.OK(w, h.getDBStats())
}
func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) {
core.OK(w, h.getRedisStats())
}
func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
response := RuntimeStats{
GoVersion: runtime.Version(),
NumGoroutine: runtime.NumGoroutine(),
NumCPU: runtime.NumCPU(),
MemAlloc: memStats.Alloc,
MemSys: memStats.Sys,
NumGC: memStats.NumGC,
}
core.OK(w, response)
}
func (h *Handler) getDBStats() *DBPoolStats {
if h.dbStats == nil {
return nil
}
stats := h.dbStats()
return &DBPoolStats{
MaxOpenConnections: stats.MaxOpenConnections,
OpenConnections: stats.OpenConnections,
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration.String(),
MaxIdleClosed: stats.MaxIdleClosed,
MaxIdleTimeClosed: stats.MaxIdleTimeClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
}
}
func (h *Handler) getRedisStats() *RedisPoolStats {
if h.redisStats == nil {
return nil
}
stats := h.redisStats()
return &RedisPoolStats{
Hits: stats.Hits,
Misses: stats.Misses,
Timeouts: stats.Timeouts,
TotalConns: stats.TotalConns,
IdleConns: stats.IdleConns,
StaleConns: stats.StaleConns,
}
}
type SystemStatsResponse struct {
Database DatabaseStatus `json:"database"`
Redis RedisStatus `json:"redis"`
Runtime RuntimeStats `json:"runtime"`
}
type DatabaseStatus struct {
Healthy bool `json:"healthy"`
Stats *DBPoolStats `json:"stats,omitempty"`
}
type RedisStatus struct {
Healthy bool `json:"healthy"`
Stats *RedisPoolStats `json:"stats,omitempty"`
}
type DBPoolStats struct {
MaxOpenConnections int `json:"max_open_connections"`
OpenConnections int `json:"open_connections"`
InUse int `json:"in_use"`
Idle int `json:"idle"`
WaitCount int64 `json:"wait_count"`
WaitDuration string `json:"wait_duration"`
MaxIdleClosed int64 `json:"max_idle_closed"`
MaxIdleTimeClosed int64 `json:"max_idle_time_closed"`
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
}
type RedisPoolStats struct {
Hits uint32 `json:"hits"`
Misses uint32 `json:"misses"`
Timeouts uint32 `json:"timeouts"`
TotalConns uint32 `json:"total_conns"`
IdleConns uint32 `json:"idle_conns"`
StaleConns uint32 `json:"stale_conns"`
}
type RuntimeStats struct {
GoVersion string `json:"go_version"`
NumGoroutine int `json:"num_goroutine"`
NumCPU int `json:"num_cpu"`
MemAlloc uint64 `json:"mem_alloc_bytes"`
MemSys uint64 `json:"mem_sys_bytes"`
NumGC uint32 `json:"num_gc"`
}

View File

@ -0,0 +1,62 @@
// AngelaMos | 2026
// dto.go
package auth
import (
"time"
)
type LoginRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
Name string `json:"name" validate:"required,min=1,max=100"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
ExpiresAt time.Time `json:"expires_at"`
}
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Tier string `json:"tier"`
CreatedAt time.Time `json:"created_at"`
}
type AuthResponse struct {
User UserResponse `json:"user"`
Tokens TokenResponse `json:"tokens"`
}
type SessionInfo struct {
ID string `json:"id"`
UserAgent string `json:"user_agent"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type SessionsResponse struct {
Sessions []SessionInfo `json:"sessions"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=8,max=128"`
}

View File

@ -0,0 +1,47 @@
// AngelaMos | 2026
// entity.go
package auth
import (
"time"
)
type RefreshToken struct {
ID string `db:"id"`
UserID string `db:"user_id"`
TokenHash string `db:"token_hash"`
FamilyID string `db:"family_id"`
ExpiresAt time.Time `db:"expires_at"`
CreatedAt time.Time `db:"created_at"`
IsUsed bool `db:"is_used"`
UsedAt *time.Time `db:"used_at"`
RevokedAt *time.Time `db:"revoked_at"`
ReplacedByID *string `db:"replaced_by_id"`
UserAgent string `db:"user_agent"`
IPAddress string `db:"ip_address"`
}
func (t *RefreshToken) IsExpired() bool {
return time.Now().After(t.ExpiresAt)
}
func (t *RefreshToken) IsRevoked() bool {
return t.RevokedAt != nil
}
func (t *RefreshToken) IsValid() bool {
return !t.IsExpired() && !t.IsRevoked() && !t.IsUsed
}
func (t *RefreshToken) MarkAsUsed(replacedByID string) {
now := time.Now()
t.IsUsed = true
t.UsedAt = &now
t.ReplacedByID = &replacedByID
}
func (t *RefreshToken) Revoke() {
now := time.Now()
t.RevokedAt = &now
}

View File

@ -0,0 +1,316 @@
// AngelaMos | 2026
// handler.go
package auth
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
"github.com/carterperez-dev/templates/go-backend/internal/core"
"github.com/carterperez-dev/templates/go-backend/internal/middleware"
)
type Handler struct {
service *Service
validator *validator.Validate
}
func NewHandler(service *Service) *Handler {
return &Handler{
service: service,
validator: validator.New(validator.WithRequiredStructEnabled()),
}
}
func (h *Handler) RegisterRoutes(
r chi.Router,
authenticator func(http.Handler) http.Handler,
) {
r.Route("/auth", func(r chi.Router) {
r.Post("/login", h.Login)
r.Post("/register", h.Register)
r.Post("/refresh", h.Refresh)
r.Group(func(r chi.Router) {
r.Use(authenticator)
r.Get("/me", h.GetMe)
r.Post("/logout", h.Logout)
r.Post("/logout-all", h.LogoutAll)
r.Get("/sessions", h.GetSessions)
r.Delete("/sessions/{sessionID}", h.RevokeSession)
r.Post("/change-password", h.ChangePassword)
})
})
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
userAgent := r.UserAgent()
ipAddress := extractIPAddress(r)
resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) {
core.JSONError(
w,
core.UnauthorizedError("invalid email or password"),
)
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, resp)
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
userAgent := r.UserAgent()
ipAddress := extractIPAddress(r)
resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress)
if err != nil {
if errors.Is(err, ErrEmailExists) {
core.JSONError(w, core.DuplicateError("email"))
return
}
core.InternalServerError(w, err)
return
}
core.Created(w, resp)
}
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
userAgent := r.UserAgent()
ipAddress := extractIPAddress(r)
resp, err := h.service.Refresh(
r.Context(),
req.RefreshToken,
userAgent,
ipAddress,
)
if err != nil {
if errors.Is(err, ErrTokenReuse) {
core.JSONError(w, core.NewAppError(
core.ErrTokenRevoked,
"security alert: token reuse detected, all sessions revoked",
http.StatusUnauthorized,
"TOKEN_REUSE_DETECTED",
))
return
}
if errors.Is(err, core.ErrTokenExpired) {
core.JSONError(w, core.TokenExpiredError())
return
}
if errors.Is(err, core.ErrTokenRevoked) {
core.JSONError(w, core.TokenRevokedError())
return
}
if errors.Is(err, core.ErrTokenInvalid) {
core.JSONError(w, core.TokenInvalidError())
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, resp)
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == "" {
core.Unauthorized(w, "")
return
}
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.service.Logout(r.Context(), req.RefreshToken, userID); err != nil {
if errors.Is(err, core.ErrForbidden) {
core.Forbidden(w, "cannot revoke another user's token")
return
}
core.InternalServerError(w, err)
return
}
core.NoContent(w)
}
func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == "" {
core.Unauthorized(w, "")
return
}
if err := h.service.LogoutAll(r.Context(), userID); err != nil {
core.InternalServerError(w, err)
return
}
core.NoContent(w)
}
func (h *Handler) GetSessions(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == "" {
core.Unauthorized(w, "")
return
}
sessions, err := h.service.GetActiveSessions(r.Context(), userID)
if err != nil {
core.InternalServerError(w, err)
return
}
core.OK(w, SessionsResponse{Sessions: sessions})
}
func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == "" {
core.Unauthorized(w, "")
return
}
sessionID := chi.URLParam(r, "sessionID")
if sessionID == "" {
core.BadRequest(w, "session ID required")
return
}
if err := h.service.RevokeSession(r.Context(), userID, sessionID); err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "session")
return
}
if errors.Is(err, core.ErrForbidden) {
core.Forbidden(w, "cannot revoke another user's session")
return
}
core.InternalServerError(w, err)
return
}
core.NoContent(w)
}
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == "" {
core.Unauthorized(w, "")
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
if err := h.service.ChangePassword(r.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
core.JSONError(
w,
core.UnauthorizedError("current password is incorrect"),
)
return
}
core.InternalServerError(w, err)
return
}
core.NoContent(w)
}
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if userID == "" {
core.Unauthorized(w, "")
return
}
user, err := h.service.GetCurrentUser(r.Context(), userID)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, user)
}
func extractIPAddress(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[len(ips)-1])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return ip
}

View File

@ -0,0 +1,295 @@
// AngelaMos | 2026
// jwt.go
package auth
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/carterperez-dev/templates/go-backend/internal/config"
"github.com/carterperez-dev/templates/go-backend/internal/core"
"github.com/carterperez-dev/templates/go-backend/internal/middleware"
)
type JWTManager struct {
privateKey jwk.Key
publicKey jwk.Key
publicJWKS jwk.Set
config config.JWTConfig
}
func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) {
privateKeyPEM, err := os.ReadFile(cfg.PrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("read private key: %w", err)
}
privateKey, err := jwk.ParseKey(privateKeyPEM, jwk.WithPEM(true))
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
if setErr := privateKey.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil {
return nil, fmt.Errorf("set algorithm: %w", setErr)
}
keyID := uuid.New().String()[:8]
if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil {
return nil, fmt.Errorf("set key id: %w", setErr)
}
publicKey, err := privateKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("derive public key: %w", err)
}
if setErr := publicKey.Set(jwk.KeyUsageKey, "sig"); setErr != nil {
return nil, fmt.Errorf("set key usage: %w", setErr)
}
publicJWKS := jwk.NewSet()
if addErr := publicJWKS.AddKey(publicKey); addErr != nil {
return nil, fmt.Errorf("add key to set: %w", addErr)
}
return &JWTManager{
privateKey: privateKey,
publicKey: publicKey,
publicJWKS: publicJWKS,
config: cfg,
}, nil
}
func GenerateKeyPair(privateKeyPath, publicKeyPath string) error {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate key: %w", err)
}
jwkPrivate, err := jwk.Import(privateKey)
if err != nil {
return fmt.Errorf("import private key: %w", err)
}
keyID := uuid.New().String()[:8]
if setErr := jwkPrivate.Set(jwk.KeyIDKey, keyID); setErr != nil {
return fmt.Errorf("set key id: %w", setErr)
}
if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil {
return fmt.Errorf("set algorithm: %w", setErr)
}
privatePEM, err := jwk.Pem(jwkPrivate)
if err != nil {
return fmt.Errorf("encode private key: %w", err)
}
if writeErr := os.WriteFile(privateKeyPath, privatePEM, 0o600); writeErr != nil {
return fmt.Errorf("write private key: %w", writeErr)
}
jwkPublic, err := jwkPrivate.PublicKey()
if err != nil {
return fmt.Errorf("derive public key: %w", err)
}
publicPEM, err := jwk.Pem(jwkPublic)
if err != nil {
return fmt.Errorf("encode public key: %w", err)
}
//nolint:gosec // G306: public key is intentionally world-readable
if writeErr := os.WriteFile(publicKeyPath, publicPEM, 0o644); writeErr != nil {
return fmt.Errorf("write public key: %w", writeErr)
}
return nil
}
type AccessTokenClaims struct {
UserID string `json:"sub"`
Role string `json:"role"`
Tier string `json:"tier"`
TokenVersion int `json:"token_version"`
}
func (m *JWTManager) CreateAccessToken(
claims AccessTokenClaims,
) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
JwtID(uuid.New().String()).
Issuer(m.config.Issuer).
Audience([]string{m.config.Audience}).
Subject(claims.UserID).
IssuedAt(now).
Expiration(now.Add(m.config.AccessTokenExpire)).
NotBefore(now).
Claim("role", claims.Role).
Claim("tier", claims.Tier).
Claim("token_version", claims.TokenVersion).
Claim("type", "access").
Build()
if err != nil {
return "", fmt.Errorf("build token: %w", err)
}
signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), m.privateKey))
if err != nil {
return "", fmt.Errorf("sign token: %w", err)
}
return string(signed), nil
}
func (m *JWTManager) VerifyAccessToken(
ctx context.Context,
tokenString string,
) (*middleware.AccessTokenClaims, error) {
token, err := jwt.Parse(
[]byte(tokenString),
jwt.WithKey(jwa.ES256(), m.publicKey),
jwt.WithValidate(true),
jwt.WithIssuer(m.config.Issuer),
jwt.WithAudience(m.config.Audience),
)
if err != nil {
if isTokenExpiredError(err) {
return nil, fmt.Errorf("verify token: %w", core.ErrTokenExpired)
}
return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid)
}
var tokenType string
if err := token.Get("type", &tokenType); err != nil ||
tokenType != "access" {
return nil, fmt.Errorf(
"verify token: invalid token type: %w",
core.ErrTokenInvalid,
)
}
subject, ok := token.Subject()
if !ok || subject == "" {
return nil, fmt.Errorf(
"verify token: missing subject: %w",
core.ErrTokenInvalid,
)
}
var roleStr string
if err := token.Get("role", &roleStr); err != nil {
return nil, fmt.Errorf(
"verify token: missing role claim: %w",
core.ErrTokenInvalid,
)
}
var tierStr string
if err := token.Get("tier", &tierStr); err != nil {
return nil, fmt.Errorf(
"verify token: missing tier claim: %w",
core.ErrTokenInvalid,
)
}
var versionFloat float64
if err := token.Get("token_version", &versionFloat); err != nil {
return nil, fmt.Errorf(
"verify token: missing token_version claim: %w",
core.ErrTokenInvalid,
)
}
return &middleware.AccessTokenClaims{
UserID: subject,
Role: roleStr,
Tier: tierStr,
TokenVersion: int(versionFloat),
}, nil
}
func isTokenExpiredError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "exp") &&
strings.Contains(errStr, "not satisfied")
}
func (m *JWTManager) GetJWKSHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
if err := json.NewEncoder(w).Encode(m.publicJWKS); err != nil {
http.Error(
w,
"Internal Server Error",
http.StatusInternalServerError,
)
return
}
}
}
func (m *JWTManager) GetPublicKey() jwk.Key {
return m.publicKey
}
func (m *JWTManager) GetKeyID() string {
var kid string
//nolint:errcheck // key ID always set during NewJWTManager init
_ = m.privateKey.Get(jwk.KeyIDKey, &kid)
return kid
}
type RefreshTokenData struct {
Token string
Hash string
ExpiresAt time.Time
FamilyID string
}
func (m *JWTManager) CreateRefreshToken(
userID, familyID string,
) (*RefreshTokenData, error) {
token, err := core.GenerateRefreshToken()
if err != nil {
return nil, fmt.Errorf("generate refresh token: %w", err)
}
hash := core.HashToken(token)
expiresAt := time.Now().Add(m.config.RefreshTokenExpire)
if familyID == "" {
familyID = uuid.New().String()
}
return &RefreshTokenData{
Token: token,
Hash: hash,
ExpiresAt: expiresAt,
FamilyID: familyID,
}, nil
}
func (m *JWTManager) VerifyRefreshTokenHash(token, storedHash string) bool {
return core.CompareTokenHash(token, storedHash)
}

View File

@ -0,0 +1,236 @@
// AngelaMos | 2026
// repository.go
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/carterperez-dev/templates/go-backend/internal/core"
)
type Repository interface {
Create(ctx context.Context, token *RefreshToken) error
FindByHash(ctx context.Context, tokenHash string) (*RefreshToken, error)
FindByID(ctx context.Context, id string) (*RefreshToken, error)
MarkAsUsed(ctx context.Context, id, replacedByID string) error
RevokeByID(ctx context.Context, id string) error
RevokeByFamilyID(ctx context.Context, familyID string) error
RevokeAllForUser(ctx context.Context, userID string) error
GetActiveSessionsForUser(
ctx context.Context,
userID string,
) ([]RefreshToken, error)
DeleteExpired(ctx context.Context) (int64, error)
}
type repository struct {
db core.DBTX
}
func NewRepository(db core.DBTX) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, token *RefreshToken) error {
query := `
INSERT INTO refresh_tokens (
id, user_id, token_hash, family_id, expires_at,
user_agent, ip_address
) VALUES (
$1, $2, $3, $4, $5, $6, $7
)
RETURNING created_at`
err := r.db.GetContext(ctx, &token.CreatedAt, query,
token.ID,
token.UserID,
token.TokenHash,
token.FamilyID,
token.ExpiresAt,
token.UserAgent,
token.IPAddress,
)
if err != nil {
return fmt.Errorf("create refresh token: %w", err)
}
return nil
}
func (r *repository) FindByHash(
ctx context.Context,
tokenHash string,
) (*RefreshToken, error) {
query := `
SELECT
id, user_id, token_hash, family_id, expires_at, created_at,
is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address
FROM refresh_tokens
WHERE token_hash = $1`
var token RefreshToken
err := r.db.GetContext(ctx, &token, query, tokenHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("find refresh token: %w", err)
}
return &token, nil
}
func (r *repository) FindByID(
ctx context.Context,
id string,
) (*RefreshToken, error) {
query := `
SELECT
id, user_id, token_hash, family_id, expires_at, created_at,
is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address
FROM refresh_tokens
WHERE id = $1`
var token RefreshToken
err := r.db.GetContext(ctx, &token, query, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("find refresh token: %w", err)
}
return &token, nil
}
func (r *repository) MarkAsUsed(
ctx context.Context,
id, replacedByID string,
) error {
query := `
UPDATE refresh_tokens
SET is_used = true, used_at = NOW(), replaced_by_id = $2
WHERE id = $1 AND is_used = false`
result, err := r.db.ExecContext(ctx, query, id, replacedByID)
if err != nil {
return fmt.Errorf("mark refresh token as used: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("mark refresh token as used: %w", err)
}
if rows == 0 {
return fmt.Errorf("mark refresh token as used: %w", core.ErrNotFound)
}
return nil
}
func (r *repository) RevokeByID(ctx context.Context, id string) error {
query := `
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
if rows == 0 {
return fmt.Errorf("revoke refresh token: %w", core.ErrNotFound)
}
return nil
}
func (r *repository) RevokeByFamilyID(
ctx context.Context,
familyID string,
) error {
query := `
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE family_id = $1 AND revoked_at IS NULL`
_, err := r.db.ExecContext(ctx, query, familyID)
if err != nil {
return fmt.Errorf("revoke token family: %w", err)
}
return nil
}
func (r *repository) RevokeAllForUser(
ctx context.Context,
userID string,
) error {
query := `
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE user_id = $1 AND revoked_at IS NULL`
_, err := r.db.ExecContext(ctx, query, userID)
if err != nil {
return fmt.Errorf("revoke all user tokens: %w", err)
}
return nil
}
func (r *repository) GetActiveSessionsForUser(
ctx context.Context,
userID string,
) ([]RefreshToken, error) {
query := `
SELECT
id, user_id, token_hash, family_id, expires_at, created_at,
is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address
FROM refresh_tokens
WHERE user_id = $1
AND revoked_at IS NULL
AND is_used = false
AND expires_at > NOW()
ORDER BY created_at DESC`
var tokens []RefreshToken
err := r.db.SelectContext(ctx, &tokens, query, userID)
if err != nil {
return nil, fmt.Errorf("get active sessions: %w", err)
}
return tokens, nil
}
func (r *repository) DeleteExpired(ctx context.Context) (int64, error) {
query := `
DELETE FROM refresh_tokens
WHERE expires_at < $1`
cutoff := time.Now().Add(-24 * time.Hour)
result, err := r.db.ExecContext(ctx, query, cutoff)
if err != nil {
return 0, fmt.Errorf("delete expired tokens: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete expired tokens: %w", err)
}
return rows, nil
}

View File

@ -0,0 +1,411 @@
// AngelaMos | 2026
// service.go
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/carterperez-dev/templates/go-backend/internal/core"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrTokenReuse = errors.New("token reuse detected")
ErrEmailExists = errors.New("email already exists")
)
type UserInfo struct {
ID string
Email string
Name string
PasswordHash string
Role string
Tier string
TokenVersion int
}
type UserProvider interface {
GetByEmail(ctx context.Context, email string) (*UserInfo, error)
GetByID(ctx context.Context, id string) (*UserInfo, error)
Create(
ctx context.Context,
email, passwordHash, name string,
) (*UserInfo, error)
IncrementTokenVersion(ctx context.Context, userID string) error
UpdatePassword(ctx context.Context, userID, passwordHash string) error
}
type Service struct {
repo Repository
jwt *JWTManager
userProvider UserProvider
redis *redis.Client
blacklistTTL time.Duration
}
func NewService(
repo Repository,
jwt *JWTManager,
userProvider UserProvider,
redisClient *redis.Client,
) *Service {
return &Service{
repo: repo,
jwt: jwt,
userProvider: userProvider,
redis: redisClient,
blacklistTTL: 15 * time.Minute,
}
}
func (s *Service) Login(
ctx context.Context,
req LoginRequest,
userAgent, ipAddress string,
) (*AuthResponse, error) {
user, err := s.userProvider.GetByEmail(ctx, req.Email)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
//nolint:errcheck // timing attack prevention - always verify to prevent enumeration
_, _, _ = core.VerifyPasswordTimingSafe(req.Password, nil)
return nil, ErrInvalidCredentials
}
return nil, fmt.Errorf("get user: %w", err)
}
valid, newHash, err := core.VerifyPasswordTimingSafe(
req.Password,
&user.PasswordHash,
)
if err != nil {
return nil, fmt.Errorf("verify password: %w", err)
}
if !valid {
return nil, ErrInvalidCredentials
}
if newHash != "" {
//nolint:errcheck // best-effort rehash upgrade
_ = s.userProvider.UpdatePassword(ctx, user.ID, newHash)
}
return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil)
}
func (s *Service) Register(
ctx context.Context,
req RegisterRequest,
userAgent, ipAddress string,
) (*AuthResponse, error) {
passwordHash, err := core.HashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
user, err := s.userProvider.Create(ctx, req.Email, passwordHash, req.Name)
if err != nil {
if errors.Is(err, core.ErrDuplicateKey) {
return nil, ErrEmailExists
}
return nil, fmt.Errorf("create user: %w", err)
}
return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil)
}
func (s *Service) Refresh(
ctx context.Context,
refreshToken, userAgent, ipAddress string,
) (*AuthResponse, error) {
tokenHash := core.HashToken(refreshToken)
storedToken, err := s.repo.FindByHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
return nil, fmt.Errorf("refresh: %w", core.ErrTokenInvalid)
}
return nil, fmt.Errorf("find token: %w", err)
}
if storedToken.IsUsed {
//nolint:errcheck // security revocation continues regardless
_ = s.repo.RevokeByFamilyID(ctx, storedToken.FamilyID)
return nil, ErrTokenReuse
}
if !storedToken.IsValid() {
if storedToken.IsRevoked() {
return nil, fmt.Errorf("refresh: %w", core.ErrTokenRevoked)
}
return nil, fmt.Errorf("refresh: %w", core.ErrTokenExpired)
}
user, err := s.userProvider.GetByID(ctx, storedToken.UserID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return s.createAuthResponse(
ctx,
user,
userAgent,
ipAddress,
storedToken.FamilyID,
&storedToken.ID,
)
}
func (s *Service) Logout(
ctx context.Context,
refreshToken, userID string,
) error {
tokenHash := core.HashToken(refreshToken)
storedToken, err := s.repo.FindByHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
return nil
}
return fmt.Errorf("find token: %w", err)
}
if storedToken.UserID != userID {
return fmt.Errorf("logout: %w", core.ErrForbidden)
}
if err := s.repo.RevokeByID(ctx, storedToken.ID); err != nil &&
!errors.Is(err, core.ErrNotFound) {
return fmt.Errorf("revoke token: %w", err)
}
return nil
}
func (s *Service) LogoutAll(ctx context.Context, userID string) error {
if err := s.repo.RevokeAllForUser(ctx, userID); err != nil {
return fmt.Errorf("revoke all tokens: %w", err)
}
if err := s.userProvider.IncrementTokenVersion(ctx, userID); err != nil {
return fmt.Errorf("increment token version: %w", err)
}
return nil
}
func (s *Service) RevokeAccessToken(
ctx context.Context,
jti string,
expiresAt time.Time,
) error {
key := "blacklist:" + jti
ttl := time.Until(expiresAt)
if ttl <= 0 {
return nil
}
if err := s.redis.Set(ctx, key, "1", ttl).Err(); err != nil {
return fmt.Errorf("blacklist token: %w", err)
}
return nil
}
func (s *Service) IsAccessTokenBlacklisted(
ctx context.Context,
jti string,
) (bool, error) {
key := "blacklist:" + jti
exists, err := s.redis.Exists(ctx, key).Result()
if err != nil {
return false, fmt.Errorf("check blacklist: %w", err)
}
return exists > 0, nil
}
func (s *Service) GetActiveSessions(
ctx context.Context,
userID string,
) ([]SessionInfo, error) {
tokens, err := s.repo.GetActiveSessionsForUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get sessions: %w", err)
}
sessions := make([]SessionInfo, 0, len(tokens))
for _, t := range tokens {
sessions = append(sessions, SessionInfo{
ID: t.ID,
UserAgent: t.UserAgent,
IPAddress: t.IPAddress,
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
})
}
return sessions, nil
}
func (s *Service) RevokeSession(
ctx context.Context,
userID, sessionID string,
) error {
token, err := s.repo.FindByID(ctx, sessionID)
if err != nil {
return fmt.Errorf("find session: %w", err)
}
if token.UserID != userID {
return fmt.Errorf("revoke session: %w", core.ErrForbidden)
}
if err := s.repo.RevokeByID(ctx, sessionID); err != nil {
return fmt.Errorf("revoke session: %w", err)
}
return nil
}
func (s *Service) ChangePassword(
ctx context.Context,
userID, currentPassword, newPassword string,
) error {
user, err := s.userProvider.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
valid, _, err := core.VerifyPasswordWithRehash(
currentPassword,
user.PasswordHash,
)
if err != nil {
return fmt.Errorf("verify password: %w", err)
}
if !valid {
return ErrInvalidCredentials
}
newHash, err := core.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := s.userProvider.UpdatePassword(ctx, userID, newHash); err != nil {
return fmt.Errorf("update password: %w", err)
}
if err := s.LogoutAll(ctx, userID); err != nil {
return fmt.Errorf("logout all: %w", err)
}
return nil
}
func (s *Service) ValidateTokenVersion(
ctx context.Context,
userID string,
tokenVersion int,
) error {
user, err := s.userProvider.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
if tokenVersion < user.TokenVersion {
return fmt.Errorf("validate token version: %w", core.ErrTokenRevoked)
}
return nil
}
func (s *Service) GetCurrentUser(
ctx context.Context,
userID string,
) (*UserResponse, error) {
user, err := s.userProvider.GetByID(ctx, userID)
if err != nil {
return nil, err
}
return &UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Role: user.Role,
Tier: user.Tier,
}, nil
}
func (s *Service) createAuthResponse(
ctx context.Context,
user *UserInfo,
userAgent, ipAddress, familyID string,
oldTokenID *string,
) (*AuthResponse, error) {
accessToken, err := s.jwt.CreateAccessToken(AccessTokenClaims{
UserID: user.ID,
Role: user.Role,
Tier: user.Tier,
TokenVersion: user.TokenVersion,
})
if err != nil {
return nil, fmt.Errorf("create access token: %w", err)
}
refreshData, err := s.jwt.CreateRefreshToken(user.ID, familyID)
if err != nil {
return nil, fmt.Errorf("create refresh token: %w", err)
}
newTokenID := uuid.New().String()
refreshTokenEntity := &RefreshToken{
ID: newTokenID,
UserID: user.ID,
TokenHash: refreshData.Hash,
FamilyID: refreshData.FamilyID,
ExpiresAt: refreshData.ExpiresAt,
UserAgent: userAgent,
IPAddress: ipAddress,
}
if err := s.repo.Create(ctx, refreshTokenEntity); err != nil {
return nil, fmt.Errorf("store refresh token: %w", err)
}
if oldTokenID != nil {
//nolint:errcheck // best-effort token chain tracking
_ = s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID)
}
return &AuthResponse{
User: UserResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Role: user.Role,
Tier: user.Tier,
CreatedAt: time.Now(),
},
Tokens: TokenResponse{
AccessToken: accessToken,
RefreshToken: refreshData.Token,
TokenType: "Bearer",
ExpiresIn: int(15 * time.Minute / time.Second),
ExpiresAt: time.Now().Add(15 * time.Minute),
},
}, nil
}

View File

@ -0,0 +1,302 @@
// AngelaMos | 2026
// config.go
package config
import (
"fmt"
"sync"
"time"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
type Config struct {
App AppConfig `koanf:"app"`
Server ServerConfig `koanf:"server"`
Database DatabaseConfig `koanf:"database"`
Redis RedisConfig `koanf:"redis"`
JWT JWTConfig `koanf:"jwt"`
RateLimit RateLimitConfig `koanf:"rate_limit"`
CORS CORSConfig `koanf:"cors"`
Log LogConfig `koanf:"log"`
Otel OtelConfig `koanf:"otel"`
}
type AppConfig struct {
Name string `koanf:"name"`
Version string `koanf:"version"`
Environment string `koanf:"environment"`
}
type ServerConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
ReadTimeout time.Duration `koanf:"read_timeout"`
WriteTimeout time.Duration `koanf:"write_timeout"`
IdleTimeout time.Duration `koanf:"idle_timeout"`
ShutdownTimeout time.Duration `koanf:"shutdown_timeout"`
}
type DatabaseConfig struct {
URL string `koanf:"url"`
MaxOpenConns int `koanf:"max_open_conns"`
MaxIdleConns int `koanf:"max_idle_conns"`
ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"`
ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"`
}
type RedisConfig struct {
URL string `koanf:"url"`
PoolSize int `koanf:"pool_size"`
MinIdleConns int `koanf:"min_idle_conns"`
}
type JWTConfig struct {
PrivateKeyPath string `koanf:"private_key_path"`
PublicKeyPath string `koanf:"public_key_path"`
AccessTokenExpire time.Duration `koanf:"access_token_expire"`
RefreshTokenExpire time.Duration `koanf:"refresh_token_expire"`
Issuer string `koanf:"issuer"`
Audience string `koanf:"audience"`
}
type RateLimitConfig struct {
Requests int `koanf:"requests"`
Window time.Duration `koanf:"window"`
Burst int `koanf:"burst"`
}
type CORSConfig struct {
AllowedOrigins []string `koanf:"allowed_origins"`
AllowedMethods []string `koanf:"allowed_methods"`
AllowedHeaders []string `koanf:"allowed_headers"`
AllowCredentials bool `koanf:"allow_credentials"`
MaxAge int `koanf:"max_age"`
}
type LogConfig struct {
Level string `koanf:"level"`
Format string `koanf:"format"`
}
type OtelConfig struct {
Endpoint string `koanf:"endpoint"`
ServiceName string `koanf:"service_name"`
Enabled bool `koanf:"enabled"`
Insecure bool `koanf:"insecure"`
SampleRate float64 `koanf:"sample_rate"`
}
var (
cfg *Config
once sync.Once
)
func Load(configPath string) (*Config, error) {
var loadErr error
once.Do(func() {
k := koanf.New(".")
if err := loadDefaults(k); err != nil {
loadErr = fmt.Errorf("load defaults: %w", err)
return
}
if configPath != "" {
if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil {
loadErr = fmt.Errorf("load config file: %w", err)
return
}
}
if err := k.Load(env.Provider("", ".", envKeyReplacer), nil); err != nil {
loadErr = fmt.Errorf("load env vars: %w", err)
return
}
cfg = &Config{}
if err := k.Unmarshal("", cfg); err != nil {
loadErr = fmt.Errorf("unmarshal config: %w", err)
return
}
if err := validate(cfg); err != nil {
loadErr = fmt.Errorf("validate config: %w", err)
return
}
})
if loadErr != nil {
return nil, loadErr
}
return cfg, nil
}
func Get() *Config {
if cfg == nil {
panic("config not loaded: call Load() first")
}
return cfg
}
func loadDefaults(k *koanf.Koanf) error {
defaults := map[string]any{
"app.name": "Go Backend",
"app.version": "1.0.0",
"app.environment": "development",
"server.host": "0.0.0.0",
"server.port": 8080,
"server.read_timeout": "30s",
"server.write_timeout": "30s",
"server.idle_timeout": "120s",
"server.shutdown_timeout": "15s",
"database.max_open_conns": 25,
"database.max_idle_conns": 5,
"database.conn_max_lifetime": "1h",
"database.conn_max_idle_time": "30m",
"redis.pool_size": 10,
"redis.min_idle_conns": 5,
"jwt.access_token_expire": "15m",
"jwt.refresh_token_expire": "168h",
"jwt.issuer": "go-backend",
"jwt.audience": "go-backend-api",
"jwt.private_key_path": "keys/private.pem",
"jwt.public_key_path": "keys/public.pem",
"rate_limit.requests": 100,
"rate_limit.window": "1m",
"rate_limit.burst": 20,
"cors.allowed_origins": []string{"http://localhost:3000"},
"cors.allowed_methods": []string{
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
},
"cors.allowed_headers": []string{
"Accept",
"Authorization",
"Content-Type",
"X-Request-ID",
},
"cors.allow_credentials": true,
"cors.max_age": 300,
"log.level": "info",
"log.format": "json",
"otel.enabled": false,
"otel.insecure": true,
"otel.sample_rate": 0.1,
"otel.service_name": "go-backend",
}
for key, value := range defaults {
if err := k.Set(key, value); err != nil {
return fmt.Errorf("set default %s: %w", key, err)
}
}
return nil
}
var envKeyMap = map[string]string{
"DATABASE_URL": "database.url",
"REDIS_URL": "redis.url",
"ENVIRONMENT": "app.environment",
"HOST": "server.host",
"PORT": "server.port",
"LOG_LEVEL": "log.level",
"LOG_FORMAT": "log.format",
"JWT_PRIVATE_KEY_PATH": "jwt.private_key_path",
"JWT_PUBLIC_KEY_PATH": "jwt.public_key_path",
"JWT_ACCESS_TOKEN_EXPIRE": "jwt.access_token_expire",
"JWT_REFRESH_TOKEN_EXPIRE": "jwt.refresh_token_expire",
"JWT_ISSUER": "jwt.issuer",
"JWT_AUDIENCE": "jwt.audience",
"RATE_LIMIT_REQUESTS": "rate_limit.requests",
"RATE_LIMIT_WINDOW": "rate_limit.window",
"RATE_LIMIT_BURST": "rate_limit.burst",
"OTEL_ENDPOINT": "otel.endpoint",
"OTEL_EXPORTER_OTLP_ENDPOINT": "otel.endpoint",
"OTEL_SERVICE_NAME": "otel.service_name",
"OTEL_ENABLED": "otel.enabled",
"OTEL_INSECURE": "otel.insecure",
"OTEL_SAMPLE_RATE": "otel.sample_rate",
}
func envKeyReplacer(s string) string {
if mapped, ok := envKeyMap[s]; ok {
return mapped
}
return ""
}
func validate(c *Config) error {
if c.Database.URL == "" {
return fmt.Errorf("DATABASE_URL is required")
}
if c.Redis.URL == "" {
return fmt.Errorf("REDIS_URL is required")
}
if c.JWT.PrivateKeyPath == "" {
return fmt.Errorf("JWT_PRIVATE_KEY_PATH is required")
}
if c.JWT.PublicKeyPath == "" {
return fmt.Errorf("JWT_PUBLIC_KEY_PATH is required")
}
if c.CORS.AllowCredentials {
for _, origin := range c.CORS.AllowedOrigins {
if origin == "*" {
return fmt.Errorf(
"CORS wildcard '*' cannot be used with AllowCredentials",
)
}
}
}
if c.App.Environment == "production" {
if c.Otel.Enabled && c.Otel.Insecure {
return fmt.Errorf("OTEL_INSECURE must be false in production")
}
}
if c.Server.ReadTimeout <= 0 {
return fmt.Errorf("server.read_timeout must be positive")
}
if c.Server.WriteTimeout <= 0 {
return fmt.Errorf("server.write_timeout must be positive")
}
return nil
}
func (c *Config) IsProduction() bool {
return c.App.Environment == "production"
}
func (c *Config) IsDevelopment() bool {
return c.App.Environment == "development"
}
func (s *ServerConfig) Address() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port)
}

View File

@ -0,0 +1,145 @@
// AngelaMos | 2026
// database.go
package core
import (
"context"
"database/sql"
"fmt"
"math/rand/v2"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/carterperez-dev/templates/go-backend/internal/config"
)
type Database struct {
DB *sqlx.DB
}
func NewDatabase(
ctx context.Context,
cfg config.DatabaseConfig,
) (*Database, error) {
db, err := sqlx.ConnectContext(ctx, "pgx", cfg.URL)
if err != nil {
return nil, fmt.Errorf("connect to database: %w", err)
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(jitteredDuration(cfg.ConnMaxLifetime))
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
_ = db.Close() //nolint:errcheck // cleanup on connection failure
return nil, fmt.Errorf("ping database: %w", err)
}
return &Database{DB: db}, nil
}
func (d *Database) Close() error {
if d.DB != nil {
return d.DB.Close()
}
return nil
}
func (d *Database) Ping(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := d.DB.PingContext(pingCtx); err != nil {
return fmt.Errorf("database ping failed: %w", err)
}
return nil
}
func (d *Database) Stats() sql.DBStats {
return d.DB.Stats()
}
type DBTX interface {
sqlx.ExtContext
sqlx.ExecerContext
GetContext(ctx context.Context, dest any, query string, args ...any) error
SelectContext(
ctx context.Context,
dest any,
query string,
args ...any,
) error
}
func InTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error {
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func InTxWithOptions(
ctx context.Context,
db *sqlx.DB,
opts *sql.TxOptions,
fn func(tx *sqlx.Tx) error,
) error {
tx, err := db.BeginTxx(ctx, opts)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic
panic(p)
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
func jitteredDuration(base time.Duration) time.Duration {
//nolint:gosec // G404: non-security-sensitive jitter for connection pool
jitter := time.Duration(rand.Int64N(int64(base / 7)))
return base + jitter
}

View File

@ -0,0 +1,169 @@
// AngelaMos | 2026
// errors.go
package core
import (
"errors"
"fmt"
"net/http"
)
var (
ErrNotFound = errors.New("resource not found")
ErrDuplicateKey = errors.New("duplicate key violation")
ErrForeignKey = errors.New("foreign key violation")
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrInternal = errors.New("internal server error")
ErrConflict = errors.New("resource conflict")
ErrRateLimited = errors.New("rate limit exceeded")
ErrTokenExpired = errors.New("token expired")
ErrTokenInvalid = errors.New("token invalid")
ErrTokenRevoked = errors.New("token revoked")
)
type AppError struct {
Err error `json:"-"`
Message string `json:"message"`
StatusCode int `json:"-"`
Code string `json:"code"`
}
func (e *AppError) Error() string {
if e.Message != "" {
return e.Message
}
if e.Err != nil {
return e.Err.Error()
}
return "unknown error"
}
func (e *AppError) Unwrap() error {
return e.Err
}
func NewAppError(
err error,
message string,
statusCode int,
code string,
) *AppError {
return &AppError{
Err: err,
Message: message,
StatusCode: statusCode,
Code: code,
}
}
func NotFoundError(resource string) *AppError {
return &AppError{
Err: ErrNotFound,
Message: fmt.Sprintf("%s not found", resource),
StatusCode: http.StatusNotFound,
Code: "NOT_FOUND",
}
}
func DuplicateError(field string) *AppError {
return &AppError{
Err: ErrDuplicateKey,
Message: fmt.Sprintf("%s already exists", field),
StatusCode: http.StatusConflict,
Code: "DUPLICATE",
}
}
func ValidationError(message string) *AppError {
return &AppError{
Err: ErrInvalidInput,
Message: message,
StatusCode: http.StatusBadRequest,
Code: "VALIDATION_ERROR",
}
}
func UnauthorizedError(message string) *AppError {
if message == "" {
message = "authentication required"
}
return &AppError{
Err: ErrUnauthorized,
Message: message,
StatusCode: http.StatusUnauthorized,
Code: "UNAUTHORIZED",
}
}
func ForbiddenError(message string) *AppError {
if message == "" {
message = "access denied"
}
return &AppError{
Err: ErrForbidden,
Message: message,
StatusCode: http.StatusForbidden,
Code: "FORBIDDEN",
}
}
func InternalError(err error) *AppError {
return &AppError{
Err: err,
Message: "internal server error",
StatusCode: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
}
}
func RateLimitError() *AppError {
return &AppError{
Err: ErrRateLimited,
Message: "too many requests",
StatusCode: http.StatusTooManyRequests,
Code: "RATE_LIMITED",
}
}
func TokenExpiredError() *AppError {
return &AppError{
Err: ErrTokenExpired,
Message: "token has expired",
StatusCode: http.StatusUnauthorized,
Code: "TOKEN_EXPIRED",
}
}
func TokenInvalidError() *AppError {
return &AppError{
Err: ErrTokenInvalid,
Message: "invalid token",
StatusCode: http.StatusUnauthorized,
Code: "TOKEN_INVALID",
}
}
func TokenRevokedError() *AppError {
return &AppError{
Err: ErrTokenRevoked,
Message: "token has been revoked",
StatusCode: http.StatusUnauthorized,
Code: "TOKEN_REVOKED",
}
}
func IsAppError(err error) bool {
var appErr *AppError
return errors.As(err, &appErr)
}
func GetAppError(err error) *AppError {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr
}
return InternalError(err)
}

View File

@ -0,0 +1,63 @@
// AngelaMos | 2026
// redis.go
package core
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/carterperez-dev/templates/go-backend/internal/config"
)
type Redis struct {
Client *redis.Client
}
func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) {
opts, err := redis.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse redis url: %w", err)
}
opts.PoolSize = cfg.PoolSize
opts.MinIdleConns = cfg.MinIdleConns
opts.PoolTimeout = 30 * time.Second
opts.ConnMaxIdleTime = 5 * time.Minute
client := redis.NewClient(opts)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
return nil, fmt.Errorf("ping redis: %w", err)
}
return &Redis{Client: client}, nil
}
func (r *Redis) Close() error {
if r.Client != nil {
return r.Client.Close()
}
return nil
}
func (r *Redis) Ping(ctx context.Context) error {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := r.Client.Ping(pingCtx).Err(); err != nil {
return fmt.Errorf("redis ping failed: %w", err)
}
return nil
}
func (r *Redis) PoolStats() *redis.PoolStats {
return r.Client.PoolStats()
}

View File

@ -0,0 +1,120 @@
// AngelaMos | 2026
// response.go
package core
import (
"encoding/json"
"log/slog"
"net/http"
)
type Response struct {
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Meta struct {
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
Total int `json:"total,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
}
func JSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
response := Response{
Success: status >= 200 && status < 300,
Data: data,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode response", "error", err)
}
}
func JSONWithMeta(w http.ResponseWriter, status int, data any, meta *Meta) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
response := Response{
Success: true,
Data: data,
Meta: meta,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode response", "error", err)
}
}
func JSONError(w http.ResponseWriter, err error) {
appErr := GetAppError(err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusCode)
response := Response{
Success: false,
Error: &Error{
Code: appErr.Code,
Message: appErr.Message,
},
}
if encErr := json.NewEncoder(w).Encode(response); encErr != nil {
slog.Error("failed to encode error response", "error", encErr)
}
}
func Created(w http.ResponseWriter, data any) {
JSON(w, http.StatusCreated, data)
}
func OK(w http.ResponseWriter, data any) {
JSON(w, http.StatusOK, data)
}
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
func BadRequest(w http.ResponseWriter, message string) {
JSONError(w, ValidationError(message))
}
func NotFound(w http.ResponseWriter, resource string) {
JSONError(w, NotFoundError(resource))
}
func Unauthorized(w http.ResponseWriter, message string) {
JSONError(w, UnauthorizedError(message))
}
func Forbidden(w http.ResponseWriter, message string) {
JSONError(w, ForbiddenError(message))
}
func InternalServerError(w http.ResponseWriter, err error) {
slog.Error("internal server error", "error", err)
JSONError(w, InternalError(err))
}
func Paginated(w http.ResponseWriter, data any, page, pageSize, total int) {
totalPages := (total + pageSize - 1) / pageSize
JSONWithMeta(w, http.StatusOK, data, &Meta{
Page: page,
PageSize: pageSize,
Total: total,
TotalPages: totalPages,
})
}

View File

@ -0,0 +1,218 @@
// AngelaMos | 2026
// security.go
package core
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonTime = 1
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
saltLength = 16
)
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
argonTime,
argonMemory,
argonThreads,
argonKeyLen,
)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argonMemory,
argonTime,
argonThreads,
b64Salt,
b64Hash,
)
return encoded, nil
}
func VerifyPassword(password, encodedHash string) (bool, error) {
params, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
otherHash := argon2.IDKey(
[]byte(password),
salt,
params.time,
params.memory,
params.threads,
params.keyLen,
)
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
return true, nil
}
return false, nil
}
func VerifyPasswordWithRehash(
password, encodedHash string,
) (bool, string, error) {
valid, err := VerifyPassword(password, encodedHash)
if err != nil {
return false, "", err
}
if !valid {
return false, "", nil
}
if needsRehash(encodedHash) {
newHash, hashErr := HashPassword(password)
if hashErr != nil {
//nolint:nilerr // password verified successfully; rehash failure is non-critical
return true, "", nil
}
return true, newHash, nil
}
return true, "", nil
}
var dummyHash string
func init() {
hash, err := HashPassword("dummy_password_for_timing_attack_prevention")
if err != nil {
panic(fmt.Sprintf("security: failed to generate dummy hash: %v", err))
}
dummyHash = hash
}
func VerifyPasswordTimingSafe(
password string,
encodedHash *string,
) (bool, string, error) {
hashToVerify := dummyHash
if encodedHash != nil && *encodedHash != "" {
hashToVerify = *encodedHash
}
valid, newHash, err := VerifyPasswordWithRehash(password, hashToVerify)
if encodedHash == nil || *encodedHash == "" {
return false, "", nil
}
return valid, newHash, err
}
type argonParams struct {
memory uint32
time uint32
threads uint8
keyLen uint32
}
func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, fmt.Errorf("invalid hash format")
}
if parts[1] != "argon2id" {
return nil, nil, nil, fmt.Errorf("unsupported algorithm: %s", parts[1])
}
var version int
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return nil, nil, nil, fmt.Errorf("invalid version: %w", err)
}
if version != argon2.Version {
return nil, nil, nil, fmt.Errorf("incompatible version: %d", version)
}
params := &argonParams{}
_, err = fmt.Sscanf(
parts[3],
"m=%d,t=%d,p=%d",
&params.memory,
&params.time,
&params.threads,
)
if err != nil {
return nil, nil, nil, fmt.Errorf("invalid params: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return nil, nil, nil, fmt.Errorf("decode salt: %w", err)
}
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return nil, nil, nil, fmt.Errorf("decode hash: %w", err)
}
//nolint:gosec // G115: hash length is always small (32 bytes for Argon2id)
params.keyLen = uint32(len(hash))
return params, salt, hash, nil
}
func needsRehash(encodedHash string) bool {
params, _, _, err := decodeHash(encodedHash)
if err != nil {
return true
}
return params.memory != argonMemory ||
params.time != argonTime ||
params.threads != argonThreads ||
params.keyLen != argonKeyLen
}
func GenerateSecureToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate random bytes: %w", err)
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func GenerateRefreshToken() (string, error) {
return GenerateSecureToken(32)
}
func HashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
func CompareTokenHash(token, hash string) bool {
tokenHash := HashToken(token)
return subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hash)) == 1
}

View File

@ -0,0 +1,142 @@
// AngelaMos | 2026
// telemetry.go
package core
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"github.com/carterperez-dev/templates/go-backend/internal/config"
)
type Telemetry struct {
TracerProvider *sdktrace.TracerProvider
Tracer trace.Tracer
}
func NewTelemetry(
ctx context.Context,
otelCfg config.OtelConfig,
appCfg config.AppConfig,
) (*Telemetry, error) {
if !otelCfg.Enabled || otelCfg.Endpoint == "" {
noopProvider := sdktrace.NewTracerProvider()
return &Telemetry{
TracerProvider: noopProvider,
Tracer: noopProvider.Tracer(otelCfg.ServiceName),
}, nil
}
opts := []otlptracegrpc.Option{
otlptracegrpc.WithEndpoint(otelCfg.Endpoint),
otlptracegrpc.WithTimeout(5 * time.Second),
}
if otelCfg.Insecure {
opts = append(
opts,
otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()),
)
} else {
opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")))
}
exporter, err := otlptracegrpc.New(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("create otlp exporter: %w", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(otelCfg.ServiceName),
semconv.ServiceVersion(appCfg.Version),
attribute.String("environment", appCfg.Environment),
),
resource.WithHost(),
resource.WithProcess(),
)
if err != nil {
return nil, fmt.Errorf("create resource: %w", err)
}
sampleRate := otelCfg.SampleRate
if sampleRate <= 0 || sampleRate > 1 {
sampleRate = 0.1
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithBatchTimeout(5*time.Second),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(sampleRate),
)),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return &Telemetry{
TracerProvider: tp,
Tracer: tp.Tracer(otelCfg.ServiceName),
}, nil
}
func (t *Telemetry) Shutdown(ctx context.Context) error {
if t.TracerProvider == nil {
return nil
}
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := t.TracerProvider.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown tracer provider: %w", err)
}
return nil
}
func SpanFromContext(ctx context.Context) trace.Span {
return trace.SpanFromContext(ctx)
}
func TraceIDFromContext(ctx context.Context) string {
span := trace.SpanFromContext(ctx)
if span.SpanContext().IsValid() {
return span.SpanContext().TraceID().String()
}
return ""
}
func AddSpanEvent(
ctx context.Context,
name string,
attrs ...attribute.KeyValue,
) {
span := trace.SpanFromContext(ctx)
span.AddEvent(name, trace.WithAttributes(attrs...))
}
func SetSpanError(ctx context.Context, err error) {
span := trace.SpanFromContext(ctx)
span.RecordError(err)
}

View File

@ -0,0 +1,42 @@
// AngelaMos | 2026
// validation.go
package core
import (
"errors"
"strings"
"github.com/go-playground/validator/v10"
)
func FormatValidationError(err error) string {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
messages := make([]string, 0, len(ve))
for _, fe := range ve {
messages = append(messages, FormatFieldError(fe))
}
return strings.Join(messages, "; ")
}
return "validation failed"
}
func FormatFieldError(fe validator.FieldError) string {
field := strings.ToLower(fe.Field())
switch fe.Tag() {
case "required":
return field + " is required"
case "email":
return field + " must be a valid email"
case "min":
return field + " must be at least " + fe.Param() + " characters"
case "max":
return field + " must be at most " + fe.Param() + " characters"
case "oneof":
return field + " must be one of: " + fe.Param()
default:
return field + " is invalid"
}
}

View File

@ -0,0 +1,195 @@
// AngelaMos | 2026
// handler.go
package health
import (
"context"
"encoding/json"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
)
type Checker interface {
Ping(ctx context.Context) error
}
type Handler struct {
db Checker
redis Checker
ready atomic.Bool
shutdown atomic.Bool
}
func NewHandler(db, redis Checker) *Handler {
h := &Handler{
db: db,
redis: redis,
}
h.ready.Store(true)
return h
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/healthz", h.Liveness)
r.Get("/livez", h.Liveness)
r.Get("/readyz", h.Readiness)
}
func (h *Handler) Liveness(w http.ResponseWriter, r *http.Request) {
if h.shutdown.Load() {
h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{
Status: "shutting_down",
})
return
}
h.writeStatus(w, http.StatusOK, StatusResponse{
Status: "ok",
})
}
func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) {
if h.shutdown.Load() {
h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{
Status: "shutting_down",
})
return
}
if !h.ready.Load() {
h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{
Status: "not_ready",
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
checks := h.runHealthChecks(ctx)
allHealthy := true
for _, check := range checks {
if !check.Healthy {
allHealthy = false
break
}
}
status := "ok"
statusCode := http.StatusOK
if !allHealthy {
status = "degraded"
statusCode = http.StatusServiceUnavailable
}
h.writeStatus(w, statusCode, ReadinessResponse{
Status: status,
Checks: checks,
})
}
func (h *Handler) runHealthChecks(ctx context.Context) []HealthCheck {
var wg sync.WaitGroup
checks := make([]HealthCheck, 2)
wg.Add(2)
go func() {
defer wg.Done()
checks[0] = h.checkDatabase(ctx)
}()
go func() {
defer wg.Done()
checks[1] = h.checkRedis(ctx)
}()
wg.Wait()
return checks
}
func (h *Handler) checkDatabase(ctx context.Context) HealthCheck {
check := HealthCheck{
Name: "database",
Healthy: true,
}
if h.db == nil {
check.Healthy = false
check.Message = "database checker not configured"
return check
}
start := time.Now()
err := h.db.Ping(ctx)
check.Latency = time.Since(start).String()
if err != nil {
check.Healthy = false
check.Message = "ping failed"
}
return check
}
func (h *Handler) checkRedis(ctx context.Context) HealthCheck {
check := HealthCheck{
Name: "redis",
Healthy: true,
}
if h.redis == nil {
check.Healthy = false
check.Message = "redis checker not configured"
return check
}
start := time.Now()
err := h.redis.Ping(ctx)
check.Latency = time.Since(start).String()
if err != nil {
check.Healthy = false
check.Message = "ping failed"
}
return check
}
func (h *Handler) SetReady(ready bool) {
h.ready.Store(ready)
}
func (h *Handler) SetShutdown(shutdown bool) {
h.shutdown.Store(shutdown)
}
func (h *Handler) writeStatus(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(status)
//nolint:errcheck // best-effort response
_ = json.NewEncoder(w).Encode(data)
}
type StatusResponse struct {
Status string `json:"status"`
}
type ReadinessResponse struct {
Status string `json:"status"`
Checks []HealthCheck `json:"checks"`
}
type HealthCheck struct {
Name string `json:"name"`
Healthy bool `json:"healthy"`
Latency string `json:"latency,omitempty"`
Message string `json:"message,omitempty"`
}

View File

@ -0,0 +1,189 @@
// AngelaMos | 2026
// auth.go
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/carterperez-dev/templates/go-backend/internal/core"
)
const (
UserIDKey contextKey = "user_id"
UserRoleKey contextKey = "user_role"
UserTierKey contextKey = "user_tier"
ClaimsKey contextKey = "jwt_claims"
)
type TokenVerifier interface {
VerifyAccessToken(
ctx context.Context,
token string,
) (*AccessTokenClaims, error)
}
type AccessTokenClaims struct {
UserID string
Role string
Tier string
TokenVersion int
}
func Authenticator(verifier TokenVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := ExtractToken(r)
if token == "" {
core.JSONError(
w,
core.UnauthorizedError("missing authorization token"),
)
return
}
claims, err := verifier.VerifyAccessToken(r.Context(), token)
if err != nil {
handleAuthError(w, err)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
ctx = context.WithValue(ctx, UserTierKey, claims.Tier)
ctx = context.WithValue(ctx, ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func OptionalAuth(verifier TokenVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := ExtractToken(r)
if token != "" {
claims, err := verifier.VerifyAccessToken(r.Context(), token)
if err == nil {
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
ctx = context.WithValue(ctx, UserTierKey, claims.Tier)
ctx = context.WithValue(ctx, ClaimsKey, claims)
r = r.WithContext(ctx)
}
}
next.ServeHTTP(w, r)
})
}
}
func RequireRole(roles ...string) func(http.Handler) http.Handler {
roleSet := make(map[string]struct{}, len(roles))
for _, role := range roles {
roleSet[role] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole := GetUserRole(r.Context())
if userRole == "" {
core.JSONError(
w,
core.UnauthorizedError("authentication required"),
)
return
}
if _, ok := roleSet[userRole]; !ok {
core.JSONError(
w,
core.ForbiddenError("insufficient permissions"),
)
return
}
next.ServeHTTP(w, r)
})
}
}
func RequireAdmin(next http.Handler) http.Handler {
return RequireRole("admin")(next)
}
func ExtractToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return ""
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
func handleAuthError(w http.ResponseWriter, err error) {
if core.IsAppError(err) {
core.JSONError(w, err)
return
}
switch {
case errors.Is(err, core.ErrTokenExpired):
core.JSONError(w, core.TokenExpiredError())
case errors.Is(err, core.ErrTokenRevoked):
core.JSONError(w, core.TokenRevokedError())
case errors.Is(err, core.ErrTokenInvalid):
core.JSONError(w, core.TokenInvalidError())
default:
core.JSONError(w, core.TokenInvalidError())
}
}
func GetUserID(ctx context.Context) string {
if id, ok := ctx.Value(UserIDKey).(string); ok {
return id
}
return ""
}
func GetUserRole(ctx context.Context) string {
if role, ok := ctx.Value(UserRoleKey).(string); ok {
return role
}
return ""
}
func GetUserTier(ctx context.Context) string {
if tier, ok := ctx.Value(UserTierKey).(string); ok {
return tier
}
return ""
}
func GetClaims(ctx context.Context) *AccessTokenClaims {
if claims, ok := ctx.Value(ClaimsKey).(*AccessTokenClaims); ok {
return claims
}
return nil
}
func IsAuthenticated(ctx context.Context) bool {
return GetUserID(ctx) != ""
}
func IsAdmin(ctx context.Context) bool {
return GetUserRole(ctx) == "admin"
}

View File

@ -0,0 +1,103 @@
// AngelaMos | 2026
// headers.go
package middleware
import (
"net/http"
"strconv"
"strings"
"github.com/carterperez-dev/templates/go-backend/internal/config"
)
func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=()",
)
if isProduction {
h.Set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload",
)
}
h.Set("Content-Security-Policy", buildCSP(isProduction))
next.ServeHTTP(w, r)
})
}
}
func buildCSP(isProduction bool) string {
directives := []string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
}
if !isProduction {
directives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
}
return strings.Join(directives, "; ")
}
func CORS(cfg config.CORSConfig) func(http.Handler) http.Handler {
allowedOrigins := make(map[string]struct{}, len(cfg.AllowedOrigins))
for _, origin := range cfg.AllowedOrigins {
allowedOrigins[origin] = struct{}{}
}
methodsStr := strings.Join(cfg.AllowedMethods, ", ")
headersStr := strings.Join(cfg.AllowedHeaders, ", ")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if _, ok := allowedOrigins[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
if cfg.AllowCredentials {
w.Header().
Set("Access-Control-Allow-Credentials", "true")
}
}
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", methodsStr)
w.Header().Set("Access-Control-Allow-Headers", headersStr)
if cfg.MaxAge > 0 {
w.Header().
Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
}
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,99 @@
// AngelaMos | 2026
// logging.go
package middleware
import (
"context"
"log/slog"
"net/http"
"time"
"go.opentelemetry.io/otel/trace"
)
type loggerKey struct{}
func Logger(baseLogger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := GetRequestID(r.Context())
reqLogger := baseLogger.With(
slog.String("request_id", requestID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
)
if span := trace.SpanFromContext(r.Context()); span.SpanContext().
IsValid() {
reqLogger = reqLogger.With(
slog.String(
"trace_id",
span.SpanContext().TraceID().String(),
),
slog.String(
"span_id",
span.SpanContext().SpanID().String(),
),
)
}
ctx := context.WithValue(r.Context(), loggerKey{}, reqLogger)
ww := &responseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
next.ServeHTTP(ww, r.WithContext(ctx))
latency := time.Since(start)
logLevel := slog.LevelInfo
if ww.status >= 500 {
logLevel = slog.LevelError
} else if ww.status >= 400 {
logLevel = slog.LevelWarn
}
reqLogger.Log(r.Context(), logLevel, "request completed",
slog.Int("status", ww.status),
slog.Int("bytes", ww.bytes),
slog.Duration("latency", latency),
slog.String("user_agent", r.UserAgent()),
)
})
}
}
func GetLogger(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
type responseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytes += n
return n, err
}
func (rw *responseWriter) Unwrap() http.ResponseWriter {
return rw.ResponseWriter
}

View File

@ -0,0 +1,375 @@
// AngelaMos | 2026
// ratelimit.go
package middleware
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
redis_rate "github.com/go-redis/redis_rate/v10"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
type RateLimitConfig struct {
Limit redis_rate.Limit
KeyFunc func(*http.Request) string
FailOpen bool
BypassFunc func(*http.Request) bool
OnLimited func(http.ResponseWriter, *http.Request, *redis_rate.Result)
}
type RateLimiter struct {
limiter *redis_rate.Limiter
fallback *localLimiter
config RateLimitConfig
}
func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiter {
if cfg.KeyFunc == nil {
cfg.KeyFunc = KeyByIP
}
return &RateLimiter{
limiter: redis_rate.NewLimiter(rdb),
fallback: newLocalLimiter(),
config: cfg,
}
}
func (rl *RateLimiter) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rl.config.BypassFunc != nil && rl.config.BypassFunc(r) {
next.ServeHTTP(w, r)
return
}
key := rl.config.KeyFunc(r)
res, err := rl.allow(r.Context(), key)
if err != nil {
if rl.config.FailOpen {
slog.Warn("rate limiter error, failing open",
"error", err,
"key", key,
)
next.ServeHTTP(w, r)
return
}
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
setRateLimitHeaders(w, res, rl.config.Limit)
if res.Allowed == 0 {
if rl.config.OnLimited != nil {
rl.config.OnLimited(w, r, res)
return
}
writeRateLimitExceeded(w, res)
return
}
next.ServeHTTP(w, r)
})
}
func (rl *RateLimiter) allow(
ctx context.Context,
key string,
) (*redis_rate.Result, error) {
res, err := rl.limiter.Allow(ctx, key, rl.config.Limit)
if err != nil {
return rl.fallback.allow(key, rl.config.Limit)
}
return res, nil
}
func KeyByIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
ip := strings.TrimSpace(ips[len(ips)-1])
return "ratelimit:ip:" + ip
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return "ratelimit:ip:" + xri
}
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
return "ratelimit:ip:" + ip
}
func KeyByUser(r *http.Request) string {
if userID := GetUserID(r.Context()); userID != "" {
return "ratelimit:user:" + userID
}
return KeyByIP(r)
}
func KeyByUserAndEndpoint(r *http.Request) string {
userKey := KeyByUser(r)
endpoint := normalizeEndpoint(r.URL.Path)
return fmt.Sprintf("%s:endpoint:%s", userKey, endpoint)
}
func normalizeEndpoint(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
normalized := make([]string, 0, len(parts))
for _, part := range parts {
if isUUID(part) || isNumeric(part) {
normalized = append(normalized, "{id}")
} else {
normalized = append(normalized, part)
}
}
return "/" + strings.Join(normalized, "/")
}
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-'
}
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func setRateLimitHeaders(
w http.ResponseWriter,
res *redis_rate.Result,
limit redis_rate.Limit,
) {
h := w.Header()
h.Set("X-RateLimit-Limit", strconv.Itoa(limit.Rate))
h.Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining))
h.Set("X-RateLimit-Reset", strconv.FormatInt(
time.Now().Add(res.ResetAfter).Unix(), 10))
windowSecs := int(limit.Period.Seconds())
h.Set("RateLimit-Policy", fmt.Sprintf(`%d;w=%d`, limit.Rate, windowSecs))
h.Set(
"RateLimit",
fmt.Sprintf(`%d;t=%d`, res.Remaining, int(res.ResetAfter.Seconds())),
)
}
func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) {
retryAfter := int(res.RetryAfter.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
response := map[string]any{
"success": false,
"error": map[string]any{
"code": "RATE_LIMITED",
"message": fmt.Sprintf(
"Rate limit exceeded. Retry after %d seconds.",
retryAfter,
),
},
}
//nolint:errcheck // best-effort response write
_ = json.NewEncoder(w).Encode(response)
}
type limiterEntry struct {
limiter *rate.Limiter
lastAccess int64
}
type localLimiter struct {
limiters sync.Map
}
const (
cleanupInterval = 5 * time.Minute
entryTTL = 10 * time.Minute
)
func newLocalLimiter() *localLimiter {
l := &localLimiter{}
go l.cleanup()
return l
}
func (l *localLimiter) cleanup() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-entryTTL).Unix()
l.limiters.Range(func(key, value any) bool {
entry, ok := value.(*limiterEntry)
if ok && entry.lastAccess < cutoff {
l.limiters.Delete(key)
}
return true
})
}
}
func (l *localLimiter) allow(
key string,
limit redis_rate.Limit,
) (*redis_rate.Result, error) {
ratePerSec := float64(limit.Rate) / limit.Period.Seconds()
now := time.Now().Unix()
entryI, loaded := l.limiters.Load(key)
if !loaded {
newEntry := &limiterEntry{
limiter: rate.NewLimiter(
rate.Limit(ratePerSec),
limit.Burst,
),
lastAccess: now,
}
entryI, _ = l.limiters.LoadOrStore(key, newEntry)
}
entry, ok := entryI.(*limiterEntry)
if !ok {
return nil, fmt.Errorf("invalid limiter entry type")
}
entry.lastAccess = now
allowed := entry.limiter.Allow()
remaining := int(entry.limiter.Tokens())
if remaining < 0 {
remaining = 0
}
var retryAfter time.Duration
if !allowed {
retryAfter = time.Duration(float64(time.Second) / ratePerSec)
} else {
retryAfter = -1
}
allowedInt := 0
if allowed {
allowedInt = 1
}
return &redis_rate.Result{
Limit: limit,
Allowed: allowedInt,
Remaining: remaining,
RetryAfter: retryAfter,
ResetAfter: time.Duration(float64(time.Second) / ratePerSec),
}, nil
}
type TierConfig struct {
RequestsPerMinute int
BurstSize int
}
var DefaultTiers = map[string]TierConfig{
"free": {RequestsPerMinute: 60, BurstSize: 10},
"pro": {RequestsPerMinute: 600, BurstSize: 100},
"enterprise": {RequestsPerMinute: 6000, BurstSize: 1000},
}
func TieredRateLimiter(
rdb *redis.Client,
tiers map[string]TierConfig,
) func(http.Handler) http.Handler {
limiter := redis_rate.NewLimiter(rdb)
fallback := newLocalLimiter()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := GetUserID(r.Context())
tier := GetUserTier(r.Context())
if tier == "" {
tier = "free"
}
config, ok := tiers[tier]
if !ok {
config = tiers["free"]
}
limit := redis_rate.Limit{
Rate: config.RequestsPerMinute,
Burst: config.BurstSize,
Period: time.Minute,
}
key := fmt.Sprintf("ratelimit:user:%s", userID)
res, err := limiter.Allow(r.Context(), key, limit)
if err != nil {
//nolint:errcheck // fallback never fails
res, _ = fallback.allow(key, limit)
}
w.Header().Set("X-RateLimit-Tier", tier)
setRateLimitHeaders(w, res, limit)
if res.Allowed == 0 {
writeRateLimitExceeded(w, res)
return
}
next.ServeHTTP(w, r)
})
}
}
func PerMinute(rate, burst int) redis_rate.Limit {
return redis_rate.Limit{
Rate: rate,
Burst: burst,
Period: time.Minute,
}
}
func PerSecond(rate, burst int) redis_rate.Limit {
return redis_rate.Limit{
Rate: rate,
Burst: burst,
Period: time.Second,
}
}
func PerHour(rate, burst int) redis_rate.Limit {
return redis_rate.Limit{
Rate: rate,
Burst: burst,
Period: time.Hour,
}
}

View File

@ -0,0 +1,40 @@
// AngelaMos | 2026
// request_id.go
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
)
type contextKey string
const RequestIDKey contextKey = "request_id"
const RequestIDHeader = "X-Request-ID"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get(RequestIDHeader)
if requestID == "" {
requestID = uuid.New().String()
}
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
w.Header().Set(RequestIDHeader, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetRequestID(ctx context.Context) string {
if id, ok := ctx.Value(RequestIDKey).(string); ok {
return id
}
return ""
}

View File

@ -0,0 +1,108 @@
// AngelaMos | 2026
// server.go
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/carterperez-dev/templates/go-backend/internal/config"
"github.com/carterperez-dev/templates/go-backend/internal/health"
)
type Server struct {
httpServer *http.Server
router *chi.Mux
config config.ServerConfig
healthHandler *health.Handler
logger *slog.Logger
}
type Config struct {
ServerConfig config.ServerConfig
HealthHandler *health.Handler
Logger *slog.Logger
}
func New(cfg Config) *Server {
router := chi.NewRouter()
router.Use(chimw.CleanPath)
router.Use(chimw.StripSlashes)
return &Server{
httpServer: &http.Server{
Addr: cfg.ServerConfig.Address(),
Handler: router,
ReadTimeout: cfg.ServerConfig.ReadTimeout,
WriteTimeout: cfg.ServerConfig.WriteTimeout,
IdleTimeout: cfg.ServerConfig.IdleTimeout,
},
router: router,
config: cfg.ServerConfig,
healthHandler: cfg.HealthHandler,
logger: cfg.Logger,
}
}
func (s *Server) Router() *chi.Mux {
return s.router
}
func (s *Server) Start() error {
s.logger.Info("starting HTTP server",
"addr", s.config.Address(),
"read_timeout", s.config.ReadTimeout,
"write_timeout", s.config.WriteTimeout,
"idle_timeout", s.config.IdleTimeout,
)
if err := s.httpServer.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("http server error: %w", err)
}
return nil
}
func (s *Server) Shutdown(ctx context.Context, drainDelay time.Duration) error {
s.logger.Info("initiating graceful shutdown")
s.logger.Info("marking server as not ready")
if s.healthHandler != nil {
s.healthHandler.SetReady(false)
s.healthHandler.SetShutdown(true)
}
s.logger.Info("waiting for load balancer to drain",
"delay", drainDelay,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(drainDelay):
}
s.logger.Info("stopping HTTP server")
shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("http server shutdown: %w", err)
}
s.logger.Info("HTTP server stopped gracefully")
return nil
}
func (s *Server) Address() string {
return s.httpServer.Addr
}

View File

@ -0,0 +1,84 @@
// AngelaMos | 2026
// dto.go
package user
import (
"time"
)
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
Name string `json:"name" validate:"required,min=1,max=100"`
}
type UpdateUserRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
}
type UpdateUserRoleRequest struct {
Role string `json:"role" validate:"required,oneof=user admin"`
}
type UpdateUserTierRequest struct {
Tier string `json:"tier" validate:"required,oneof=free pro enterprise"`
}
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Tier string `json:"tier"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserListResponse struct {
Users []UserResponse `json:"users"`
}
type ListUsersParams struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Search string `json:"search"`
Role string `json:"role"`
Tier string `json:"tier"`
}
func (p *ListUsersParams) Normalize() {
if p.Page < 1 {
p.Page = 1
}
if p.PageSize < 1 {
p.PageSize = 20
}
if p.PageSize > 100 {
p.PageSize = 100
}
}
func (p *ListUsersParams) Offset() int {
return (p.Page - 1) * p.PageSize
}
func ToUserResponse(u *User) UserResponse {
return UserResponse{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Role: u.Role,
Tier: u.Tier,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func ToUserResponseList(users []User) []UserResponse {
responses := make([]UserResponse, 0, len(users))
for _, u := range users {
responses = append(responses, ToUserResponse(&u))
}
return responses
}

View File

@ -0,0 +1,40 @@
// AngelaMos | 2026
// entity.go
package user
import (
"time"
)
type User struct {
ID string `db:"id"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
Name string `db:"name"`
Role string `db:"role"`
Tier string `db:"tier"`
TokenVersion int `db:"token_version"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}
func (u *User) IsDeleted() bool {
return u.DeletedAt != nil
}
func (u *User) IsAdmin() bool {
return u.Role == RoleAdmin
}
const (
RoleUser = "user"
RoleAdmin = "admin"
)
const (
TierFree = "free"
TierPro = "pro"
TierEnterprise = "enterprise"
)

View File

@ -0,0 +1,288 @@
// AngelaMos | 2026
// handler.go
package user
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
"github.com/carterperez-dev/templates/go-backend/internal/core"
"github.com/carterperez-dev/templates/go-backend/internal/middleware"
)
type Handler struct {
service *Service
validator *validator.Validate
}
func NewHandler(service *Service) *Handler {
return &Handler{
service: service,
validator: validator.New(validator.WithRequiredStructEnabled()),
}
}
func (h *Handler) RegisterRoutes(
r chi.Router,
authenticator func(http.Handler) http.Handler,
) {
r.Route("/users", func(r chi.Router) {
r.Use(authenticator)
r.Get("/me", h.GetMe)
r.Put("/me", h.UpdateMe)
r.Delete("/me", h.DeleteMe)
})
}
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
user, err := h.service.GetMe(r.Context(), userID)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, ToUserResponse(user))
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
user, err := h.service.UpdateMe(r.Context(), userID, req)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, ToUserResponse(user))
}
func (h *Handler) DeleteMe(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context())
if err := h.service.DeleteMe(r.Context(), userID); err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.NoContent(w)
}
// RegisterAdminRoutes registers admin-only user management endpoints.
func (h *Handler) RegisterAdminRoutes(
r chi.Router,
authenticator, adminOnly func(http.Handler) http.Handler,
) {
r.Route("/admin/users", func(r chi.Router) {
r.Use(authenticator)
r.Use(adminOnly)
r.Get("/", h.ListUsers)
r.Get("/{userID}", h.GetUser)
r.Put("/{userID}", h.UpdateUser)
r.Put("/{userID}/role", h.UpdateUserRole)
r.Put("/{userID}/tier", h.UpdateUserTier)
r.Delete("/{userID}", h.DeleteUser)
})
}
// ListUsers returns a paginated list of users with optional filtering.
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
params := ListUsersParams{
Page: parseIntQuery(r, "page", 1),
PageSize: parseIntQuery(r, "page_size", 20),
Search: r.URL.Query().Get("search"),
Role: r.URL.Query().Get("role"),
Tier: r.URL.Query().Get("tier"),
}
users, total, err := h.service.ListUsers(r.Context(), params)
if err != nil {
core.InternalServerError(w, err)
return
}
core.Paginated(
w,
ToUserResponseList(users),
params.Page,
params.PageSize,
total,
)
}
// GetUser returns a specific user by ID (admin only).
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
user, err := h.service.GetUser(r.Context(), userID)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, ToUserResponse(user))
}
// UpdateUser updates a specific user's profile (admin only).
func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
user, err := h.service.UpdateUser(r.Context(), userID, req)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, ToUserResponse(user))
}
// UpdateUserRole changes a user's role (admin only).
func (h *Handler) UpdateUserRole(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
var req UpdateUserRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
user, err := h.service.UpdateUserRole(r.Context(), userID, req.Role)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, ToUserResponse(user))
}
// UpdateUserTier changes a user's subscription tier (admin only).
func (h *Handler) UpdateUserTier(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
var req UpdateUserTierRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
core.BadRequest(w, "invalid request body")
return
}
if err := h.validator.Struct(req); err != nil {
core.BadRequest(w, core.FormatValidationError(err))
return
}
user, err := h.service.UpdateUserTier(r.Context(), userID, req.Tier)
if err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.OK(w, ToUserResponse(user))
}
// DeleteUser soft deletes a user account (admin only).
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
requesterID := middleware.GetUserID(r.Context())
targetID := chi.URLParam(r, "userID")
if err := h.service.CanDeleteUser(r.Context(), requesterID, targetID); err != nil {
if errors.Is(err, core.ErrForbidden) {
core.Forbidden(w, "insufficient permissions")
return
}
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
if err := h.service.DeleteUser(r.Context(), targetID); err != nil {
if errors.Is(err, core.ErrNotFound) {
core.NotFound(w, "user")
return
}
core.InternalServerError(w, err)
return
}
core.NoContent(w)
}
func parseIntQuery(r *http.Request, key string, defaultVal int) int {
val := r.URL.Query().Get(key)
if val == "" {
return defaultVal
}
parsed, err := strconv.Atoi(val)
if err != nil {
return defaultVal
}
return parsed
}

View File

@ -0,0 +1,289 @@
// AngelaMos | 2026
// repository.go
package user
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgconn"
"github.com/carterperez-dev/templates/go-backend/internal/core"
)
type Repository interface {
Create(ctx context.Context, user *User) error
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
Update(ctx context.Context, user *User) error
UpdatePassword(ctx context.Context, id, passwordHash string) error
IncrementTokenVersion(ctx context.Context, id string) error
SoftDelete(ctx context.Context, id string) error
List(ctx context.Context, params ListUsersParams) ([]User, int, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
}
type repository struct {
db core.DBTX
}
func NewRepository(db core.DBTX) Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, user *User) error {
query := `
INSERT INTO users (id, email, password_hash, name, role, tier)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at, token_version`
err := r.db.GetContext(ctx, user, query,
user.ID,
user.Email,
user.PasswordHash,
user.Name,
user.Role,
user.Tier,
)
if err != nil {
if isDuplicateKeyError(err) {
return fmt.Errorf("create user: %w", core.ErrDuplicateKey)
}
return fmt.Errorf("create user: %w", err)
}
return nil
}
func (r *repository) GetByID(ctx context.Context, id string) (*User, error) {
query := `
SELECT id, email, password_hash, name, role, tier, token_version,
created_at, updated_at, deleted_at
FROM users
WHERE id = $1 AND deleted_at IS NULL`
var user User
err := r.db.GetContext(ctx, &user, query, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user: %w", core.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &user, nil
}
func (r *repository) GetByEmail(
ctx context.Context,
email string,
) (*User, error) {
query := `
SELECT id, email, password_hash, name, role, tier, token_version,
created_at, updated_at, deleted_at
FROM users
WHERE email = $1 AND deleted_at IS NULL`
var user User
err := r.db.GetContext(ctx, &user, query, email)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user by email: %w", core.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get user by email: %w", err)
}
return &user, nil
}
func (r *repository) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET name = $2, role = $3, tier = $4, updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING updated_at`
err := r.db.GetContext(ctx, &user.UpdatedAt, query,
user.ID,
user.Name,
user.Role,
user.Tier,
)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("update user: %w", core.ErrNotFound)
}
if err != nil {
return fmt.Errorf("update user: %w", err)
}
return nil
}
func (r *repository) UpdatePassword(
ctx context.Context,
id, passwordHash string,
) error {
query := `
UPDATE users
SET password_hash = $2, updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`
result, err := r.db.ExecContext(ctx, query, id, passwordHash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update password: %w", err)
}
if rows == 0 {
return fmt.Errorf("update password: %w", core.ErrNotFound)
}
return nil
}
func (r *repository) IncrementTokenVersion(
ctx context.Context,
id string,
) error {
query := `
UPDATE users
SET token_version = token_version + 1, updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("increment token version: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("increment token version: %w", err)
}
if rows == 0 {
return fmt.Errorf("increment token version: %w", core.ErrNotFound)
}
return nil
}
func (r *repository) SoftDelete(ctx context.Context, id string) error {
query := `
UPDATE users
SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
if rows == 0 {
return fmt.Errorf("delete user: %w", core.ErrNotFound)
}
return nil
}
func (r *repository) List(
ctx context.Context,
params ListUsersParams,
) ([]User, int, error) {
params.Normalize()
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, "deleted_at IS NULL")
if params.Search != "" {
conditions = append(conditions, fmt.Sprintf(
"(email ILIKE $%d OR name ILIKE $%d)", argIdx, argIdx))
args = append(args, "%"+escapeLike(params.Search)+"%")
argIdx++
}
if params.Role != "" {
conditions = append(conditions, fmt.Sprintf("role = $%d", argIdx))
args = append(args, params.Role)
argIdx++
}
if params.Tier != "" {
conditions = append(conditions, fmt.Sprintf("tier = $%d", argIdx))
args = append(args, params.Tier)
argIdx++
}
whereClause := strings.Join(conditions, " AND ")
countQuery := fmt.Sprintf(
"SELECT COUNT(*) FROM users WHERE %s",
whereClause,
)
var total int
if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil {
return nil, 0, fmt.Errorf("count users: %w", err)
}
query := fmt.Sprintf(`
SELECT id, email, name, role, tier, token_version,
created_at, updated_at, deleted_at
FROM users
WHERE %s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d`,
whereClause, argIdx, argIdx+1)
args = append(args, params.PageSize, params.Offset())
var users []User
if err := r.db.SelectContext(ctx, &users, query, args...); err != nil {
return nil, 0, fmt.Errorf("list users: %w", err)
}
return users, total, nil
}
func (r *repository) ExistsByEmail(
ctx context.Context,
email string,
) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND deleted_at IS NULL)`
var exists bool
if err := r.db.GetContext(ctx, &exists, query, email); err != nil {
return false, fmt.Errorf("check email exists: %w", err)
}
return exists, nil
}
func isDuplicateKeyError(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "%", "\\%")
s = strings.ReplaceAll(s, "_", "\\_")
return s
}

View File

@ -0,0 +1,256 @@
// AngelaMos | 2026
// service.go
package user
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/carterperez-dev/templates/go-backend/internal/auth"
"github.com/carterperez-dev/templates/go-backend/internal/core"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetByID(
ctx context.Context,
id string,
) (*auth.UserInfo, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
return toUserInfo(user), nil
}
func (s *Service) GetByEmail(
ctx context.Context,
email string,
) (*auth.UserInfo, error) {
user, err := s.repo.GetByEmail(ctx, strings.ToLower(email))
if err != nil {
return nil, err
}
return toUserInfo(user), nil
}
func (s *Service) Create(
ctx context.Context,
email, passwordHash, name string,
) (*auth.UserInfo, error) {
user := &User{
ID: uuid.New().String(),
Email: strings.ToLower(email),
PasswordHash: passwordHash,
Name: name,
Role: RoleUser,
Tier: TierFree,
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, err
}
return toUserInfo(user), nil
}
func (s *Service) IncrementTokenVersion(
ctx context.Context,
userID string,
) error {
return s.repo.IncrementTokenVersion(ctx, userID)
}
func (s *Service) UpdatePassword(
ctx context.Context,
userID, passwordHash string,
) error {
return s.repo.UpdatePassword(ctx, userID, passwordHash)
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) UpdateUser(
ctx context.Context,
id string,
req UpdateUserRequest,
) (*User, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
user.Name = *req.Name
}
if err := s.repo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) UpdateUserRole(
ctx context.Context,
id, role string,
) (*User, error) {
if role != RoleUser && role != RoleAdmin {
return nil, fmt.Errorf(
"update role: invalid role %q: %w",
role,
core.ErrInvalidInput,
)
}
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
user.Role = role
if err := s.repo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) UpdateUserTier(
ctx context.Context,
id, tier string,
) (*User, error) {
if tier != TierFree && tier != TierPro && tier != TierEnterprise {
return nil, fmt.Errorf(
"update tier: invalid tier %q: %w",
tier,
core.ErrInvalidInput,
)
}
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
user.Tier = tier
if err := s.repo.Update(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) DeleteUser(ctx context.Context, id string) error {
return s.repo.SoftDelete(ctx, id)
}
func (s *Service) ListUsers(
ctx context.Context,
params ListUsersParams,
) ([]User, int, error) {
return s.repo.List(ctx, params)
}
func (s *Service) GetMe(ctx context.Context, userID string) (*User, error) {
if userID == "" {
return nil, fmt.Errorf("get me: %w", core.ErrUnauthorized)
}
user, err := s.repo.GetByID(ctx, userID)
if err != nil {
return nil, err
}
return user, nil
}
func (s *Service) UpdateMe(
ctx context.Context,
userID string,
req UpdateUserRequest,
) (*User, error) {
if userID == "" {
return nil, fmt.Errorf("update me: %w", core.ErrUnauthorized)
}
return s.UpdateUser(ctx, userID, req)
}
func (s *Service) DeleteMe(ctx context.Context, userID string) error {
if userID == "" {
return fmt.Errorf("delete me: %w", core.ErrUnauthorized)
}
return s.repo.SoftDelete(ctx, userID)
}
func (s *Service) EmailExists(
ctx context.Context,
email string,
) (bool, error) {
exists, err := s.repo.ExistsByEmail(ctx, email)
if err != nil {
return false, err
}
return exists, nil
}
func (s *Service) CanDeleteUser(
ctx context.Context,
requesterID, targetID string,
) error {
if requesterID == targetID {
return nil
}
requester, err := s.repo.GetByID(ctx, requesterID)
if err != nil {
return err
}
if !requester.IsAdmin() {
return fmt.Errorf("delete user: %w", core.ErrForbidden)
}
target, err := s.repo.GetByID(ctx, targetID)
if err != nil {
return err
}
if target.IsAdmin() {
return fmt.Errorf("cannot delete admin users: %w", core.ErrForbidden)
}
return nil
}
func toUserInfo(u *User) *auth.UserInfo {
return &auth.UserInfo{
ID: u.ID,
Email: u.Email,
Name: u.Name,
PasswordHash: u.PasswordHash,
Role: u.Role,
Tier: u.Tier,
TokenVersion: u.TokenVersion,
}
}
var _ auth.UserProvider = (*Service)(nil)

View File

@ -0,0 +1,19 @@
# ©AngelaMos | 2026
# go.docker
FROM golang:1.25-bookworm
WORKDIR /app
RUN go install github.com/air-verse/air@latest \
&& go install github.com/pressly/goose/v3/cmd/goose@latest
COPY backend/go.mod backend/go.sum ./
RUN go mod download
ENV GO_ENV=development \
PATH=/go/bin:$PATH
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]

View File

@ -0,0 +1,14 @@
# ©AngelaMos | 2026
# vite.docker
FROM node:24-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=development
EXPOSE 5173
CMD ["pnpm", "dev", "--host", "0.0.0.0"]

View File

@ -0,0 +1,26 @@
# ©AngelaMos | 2026
# go.docker
FROM golang:1.25-bookworm AS builder
WORKDIR /build
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 GOOS=linux \
go build -trimpath -ldflags="-s -w" -o /app ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
COPY --from=builder /build/migrations /migrations
COPY --from=builder /build/config.yaml /config.yaml
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app", "-config", "/config.yaml"]

View File

@ -0,0 +1,26 @@
# ©AngelaMos | 2026
# vite.docker
FROM node:24-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY frontend/ .
RUN pnpm build
FROM nginx:1.27-alpine
COPY conf/nginx/nginx.prod.conf /etc/nginx/nginx.conf
COPY conf/nginx/prod.nginx /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,15 @@
node_modules
build
dist
.git
.gitignore
*.md
.env*
.vscode
.idea
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
coverage
.nyc_output

View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vite
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,22 @@
# ©AngelaMos | 2025
# .stylelintignore
# Dependencies
node_modules/
# Production builds
dist/
build/
out/
# JS/TS files
**/*.js
**/*.jsx
**/*.ts
**/*.tsx
# Generated files
*.min.css
# Error system styles - ignore from linting
src/core/app/_toastStyles.scss

View File

@ -0,0 +1,94 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 82,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "error",
"options": { "maxAllowedComplexity": 25 }
},
"noForEach": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "error",
"noUndeclaredVariables": "error"
},
"style": {
"useImportType": "error",
"useConst": "error",
"useTemplate": "error",
"useSelfClosingElements": "error",
"useFragmentSyntax": "error",
"noNonNullAssertion": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "shorthand" }
},
"useNamingConvention": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noDebugger": "error",
"noConsole": "warn",
"noArrayIndexKey": "warn",
"noAssignInExpressions": "error",
"noDoubleEquals": "error",
"noRedeclare": "error",
"noVar": "error"
},
"security": {
"noDangerouslySetInnerHtml": "error"
},
"a11y": {
"useAltText": "error",
"useAnchorContent": "error",
"useKeyWithClickEvents": "error",
"noStaticElementInteractions": "error",
"useButtonType": "error",
"useValidAnchor": "error"
}
}
},
"overrides": [
{
"includes": ["src/main.tsx"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}

View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<!--
Monitor The Situation Dashboard
Author(s): © AngelaMos, CarterPerez-dev
-->
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="/assets/favicon.ico"
/>
<link
rel="apple-touch-icon"
type="image/png"
href="/assets/apple-touch-icon.png"
/>
<link
rel="manifest"
href="/assets/site.webmanifest"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Monitor The Situation Dashboard</title>
<meta
name="description"
content="*Cracked*"
/>
<meta
name="author"
content=" ©AngelaMos | 2026 | CarterPerez-dev"
/>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
></script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{
"name": "monitor-the-situation-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc -b",
"lint:scss": "stylelint '**/*.scss'",
"lint:scss:fix": "stylelint '**/*.scss' --fix"
},
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.0",
"react": "^19.2.1",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"react-icon": "^1.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.1.1",
"sonner": "^2.0.7",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"sass": "^1.95.0",
"stylelint": "^16.26.1",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5",
"vite-tsconfig-paths": "^5.1.0"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,36 @@
// ===========================
// ©AngelaMos | 2025
// App.tsx
// ===========================
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider } from 'react-router-dom'
import { Toaster } from 'sonner'
import { queryClient } from '@/core/api'
import { router } from '@/core/app/routers'
import '@/core/app/toast.module.scss'
export default function App(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<div className="app">
<RouterProvider router={router} />
<Toaster
position="top-right"
duration={2000}
theme="dark"
toastOptions={{
style: {
background: 'hsl(0, 0%, 12.2%)',
border: '1px solid hsl(0, 0%, 18%)',
color: 'hsl(0, 0%, 98%)',
},
}}
/>
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './useAdmin'
export * from './useAuth'
export * from './useUsers'

View File

@ -0,0 +1,219 @@
// ===================
// © AngelaMos | 2025
// useAdmin.ts
// ===================
import {
type UseMutationResult,
type UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { toast } from 'sonner'
import {
type AdminUserCreateRequest,
type AdminUserUpdateRequest,
isValidUserListResponse,
isValidUserResponse,
USER_ERROR_MESSAGES,
USER_SUCCESS_MESSAGES,
type UserListResponse,
type UserResponse,
UserResponseError,
} from '@/api/types'
import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
export const adminQueries = {
all: () => QUERY_KEYS.ADMIN.ALL,
users: {
all: () => QUERY_KEYS.ADMIN.USERS.ALL(),
list: (page: number, size: number) => QUERY_KEYS.ADMIN.USERS.LIST(page, size),
byId: (id: string) => QUERY_KEYS.ADMIN.USERS.BY_ID(id),
},
} as const
interface UseAdminUsersParams {
page?: number
size?: number
}
const fetchAdminUsers = async (
page: number,
size: number
): Promise<UserListResponse> => {
const response = await apiClient.get<unknown>(API_ENDPOINTS.ADMIN.USERS.LIST, {
params: { page, size },
})
const data: unknown = response.data
if (!isValidUserListResponse(data)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_LIST_RESPONSE,
API_ENDPOINTS.ADMIN.USERS.LIST
)
}
return data
}
export const useAdminUsers = (
params: UseAdminUsersParams = {}
): UseQueryResult<UserListResponse, Error> => {
const page = params.page ?? PAGINATION.DEFAULT_PAGE
const size = params.size ?? PAGINATION.DEFAULT_SIZE
return useQuery({
queryKey: adminQueries.users.list(page, size),
queryFn: () => fetchAdminUsers(page, size),
...QUERY_STRATEGIES.standard,
})
}
const fetchAdminUserById = async (id: string): Promise<UserResponse> => {
const response = await apiClient.get<unknown>(
API_ENDPOINTS.ADMIN.USERS.BY_ID(id)
)
const data: unknown = response.data
if (!isValidUserResponse(data)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.ADMIN.USERS.BY_ID(id)
)
}
return data
}
export const useAdminUser = (id: string): UseQueryResult<UserResponse, Error> => {
return useQuery({
queryKey: adminQueries.users.byId(id),
queryFn: () => fetchAdminUserById(id),
enabled: id.length > 0,
...QUERY_STRATEGIES.standard,
})
}
const performAdminCreateUser = async (
data: AdminUserCreateRequest
): Promise<UserResponse> => {
const response = await apiClient.post<unknown>(
API_ENDPOINTS.ADMIN.USERS.CREATE,
data
)
const responseData: unknown = response.data
if (!isValidUserResponse(responseData)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.ADMIN.USERS.CREATE
)
}
return responseData
}
export const useAdminCreateUser = (): UseMutationResult<
UserResponse,
Error,
AdminUserCreateRequest
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: performAdminCreateUser,
onSuccess: async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() })
toast.success(USER_SUCCESS_MESSAGES.CREATED)
},
onError: (error: Error): void => {
const message =
error instanceof UserResponseError
? error.message
: USER_ERROR_MESSAGES.FAILED_TO_CREATE
toast.error(message)
},
})
}
interface AdminUpdateUserParams {
id: string
data: AdminUserUpdateRequest
}
const performAdminUpdateUser = async (
params: AdminUpdateUserParams
): Promise<UserResponse> => {
const response = await apiClient.patch<unknown>(
API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id),
params.data
)
const responseData: unknown = response.data
if (!isValidUserResponse(responseData)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id)
)
}
return responseData
}
export const useAdminUpdateUser = (): UseMutationResult<
UserResponse,
Error,
AdminUpdateUserParams
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: performAdminUpdateUser,
onSuccess: async (
data: UserResponse,
variables: AdminUpdateUserParams
): Promise<void> => {
queryClient.setQueryData(adminQueries.users.byId(variables.id), data)
await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() })
toast.success(USER_SUCCESS_MESSAGES.UPDATED)
},
onError: (error: Error): void => {
const message =
error instanceof UserResponseError
? error.message
: USER_ERROR_MESSAGES.FAILED_TO_UPDATE
toast.error(message)
},
})
}
const performAdminDeleteUser = async (id: string): Promise<void> => {
await apiClient.delete(API_ENDPOINTS.ADMIN.USERS.DELETE(id))
}
export const useAdminDeleteUser = (): UseMutationResult<void, Error, string> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: performAdminDeleteUser,
onSuccess: async (_, id: string): Promise<void> => {
queryClient.removeQueries({ queryKey: adminQueries.users.byId(id) })
await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() })
toast.success(USER_SUCCESS_MESSAGES.DELETED)
},
onError: (error: Error): void => {
const message =
error instanceof UserResponseError
? error.message
: USER_ERROR_MESSAGES.FAILED_TO_DELETE
toast.error(message)
},
})
}

View File

@ -0,0 +1,243 @@
// ===================
// © AngelaMos | 2025
// useAuth.ts
// ===================
import {
type UseMutationResult,
type UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { toast } from 'sonner'
import {
AUTH_ERROR_MESSAGES,
AUTH_SUCCESS_MESSAGES,
AuthResponseError,
isValidLogoutAllResponse,
isValidTokenResponse,
isValidTokenWithUserResponse,
isValidUserResponse,
type LoginRequest,
type LogoutAllResponse,
type PasswordChangeRequest,
type TokenWithUserResponse,
type UserResponse,
} from '@/api/types'
import { API_ENDPOINTS, QUERY_KEYS, ROUTES } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
import { useAuthStore } from '@/core/lib'
export const authQueries = {
all: () => QUERY_KEYS.AUTH.ALL,
me: () => QUERY_KEYS.AUTH.ME(),
} as const
const fetchCurrentUser = async (): Promise<UserResponse> => {
const response = await apiClient.get<unknown>(API_ENDPOINTS.AUTH.ME)
const data: unknown = response.data
if (!isValidUserResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.AUTH.ME
)
}
return data
}
export const useCurrentUser = (): UseQueryResult<UserResponse, Error> => {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return useQuery({
queryKey: authQueries.me(),
queryFn: fetchCurrentUser,
enabled: isAuthenticated,
...QUERY_STRATEGIES.auth,
})
}
const performLogin = async (
credentials: LoginRequest
): Promise<TokenWithUserResponse> => {
const formData = new URLSearchParams()
formData.append('username', credentials.username)
formData.append('password', credentials.password)
const response = await apiClient.post<unknown>(
API_ENDPOINTS.AUTH.LOGIN,
formData,
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
)
const data: unknown = response.data
if (!isValidTokenWithUserResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_LOGIN_RESPONSE,
API_ENDPOINTS.AUTH.LOGIN
)
}
return data
}
export const useLogin = (): UseMutationResult<
TokenWithUserResponse,
Error,
LoginRequest
> => {
const queryClient = useQueryClient()
const login = useAuthStore((s) => s.login)
return useMutation({
mutationFn: performLogin,
onSuccess: (data: TokenWithUserResponse): void => {
login(data.user, data.access_token)
queryClient.setQueryData(authQueries.me(), data.user)
const welcomeMessage = AUTH_SUCCESS_MESSAGES.WELCOME_BACK(
data.user.full_name
)
toast.success(welcomeMessage)
},
onError: (error: Error): void => {
const message =
error instanceof AuthResponseError ? error.message : 'Login failed'
toast.error(message)
},
})
}
const performLogout = async (): Promise<void> => {
await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT)
}
export const useLogout = (): UseMutationResult<void, Error, void> => {
const queryClient = useQueryClient()
const logout = useAuthStore((s) => s.logout)
return useMutation({
mutationFn: performLogout,
onSuccess: (): void => {
logout()
queryClient.removeQueries({ queryKey: authQueries.all() })
toast.success(AUTH_SUCCESS_MESSAGES.LOGOUT_SUCCESS)
window.location.href = ROUTES.LOGIN
},
onError: (): void => {
logout()
queryClient.removeQueries({ queryKey: authQueries.all() })
window.location.href = ROUTES.LOGIN
},
})
}
const performLogoutAll = async (): Promise<LogoutAllResponse> => {
const response = await apiClient.post<unknown>(API_ENDPOINTS.AUTH.LOGOUT_ALL)
const data: unknown = response.data
if (!isValidLogoutAllResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_LOGOUT_RESPONSE,
API_ENDPOINTS.AUTH.LOGOUT_ALL
)
}
return data
}
export const useLogoutAll = (): UseMutationResult<
LogoutAllResponse,
Error,
void
> => {
const queryClient = useQueryClient()
const logout = useAuthStore((s) => s.logout)
return useMutation({
mutationFn: performLogoutAll,
onSuccess: (data: LogoutAllResponse): void => {
logout()
queryClient.removeQueries({ queryKey: authQueries.all() })
toast.success(`Logged out from ${data.revoked_sessions} session(s)`)
window.location.href = ROUTES.LOGIN
},
onError: (error: Error): void => {
const message =
error instanceof AuthResponseError
? error.message
: 'Failed to logout all sessions'
toast.error(message)
},
})
}
const performPasswordChange = async (
data: PasswordChangeRequest
): Promise<void> => {
await apiClient.post(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, data)
}
export const useChangePassword = (): UseMutationResult<
void,
Error,
PasswordChangeRequest
> => {
return useMutation({
mutationFn: performPasswordChange,
onSuccess: (): void => {
toast.success(AUTH_SUCCESS_MESSAGES.PASSWORD_CHANGED)
},
onError: (error: Error): void => {
const message =
error instanceof AuthResponseError
? error.message
: 'Failed to change password'
toast.error(message)
},
})
}
export const useRefreshAuth = (): (() => Promise<void>) => {
const queryClient = useQueryClient()
const { setAccessToken, login, logout } = useAuthStore()
return async (): Promise<void> => {
try {
const response = await apiClient.post<unknown>(API_ENDPOINTS.AUTH.REFRESH)
const data: unknown = response.data
if (!isValidTokenResponse(data)) {
throw new AuthResponseError(
AUTH_ERROR_MESSAGES.INVALID_TOKEN_RESPONSE,
API_ENDPOINTS.AUTH.REFRESH
)
}
setAccessToken(data.access_token)
const userResponse = await apiClient.get<unknown>(API_ENDPOINTS.AUTH.ME)
const userData: unknown = userResponse.data
if (isValidUserResponse(userData)) {
login(userData, data.access_token)
queryClient.setQueryData(authQueries.me(), userData)
}
} catch {
logout()
queryClient.removeQueries({ queryKey: authQueries.all() })
}
}
}

View File

@ -0,0 +1,138 @@
// ===================
// © AngelaMos | 2025
// useUsers.ts
// ===================
import {
type UseMutationResult,
type UseQueryResult,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { toast } from 'sonner'
import {
isValidUserResponse,
USER_ERROR_MESSAGES,
USER_SUCCESS_MESSAGES,
type UserCreateRequest,
type UserResponse,
UserResponseError,
type UserUpdateRequest,
} from '@/api/types'
import { API_ENDPOINTS, QUERY_KEYS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
import { useAuthStore } from '@/core/lib'
import { authQueries } from './useAuth'
export const userQueries = {
all: () => QUERY_KEYS.USERS.ALL,
byId: (id: string) => QUERY_KEYS.USERS.BY_ID(id),
me: () => QUERY_KEYS.USERS.ME(),
} as const
const fetchUserById = async (id: string): Promise<UserResponse> => {
const response = await apiClient.get<unknown>(API_ENDPOINTS.USERS.BY_ID(id))
const data: unknown = response.data
if (!isValidUserResponse(data)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.USERS.BY_ID(id)
)
}
return data
}
export const useUser = (id: string): UseQueryResult<UserResponse, Error> => {
return useQuery({
queryKey: userQueries.byId(id),
queryFn: () => fetchUserById(id),
enabled: id.length > 0,
...QUERY_STRATEGIES.standard,
})
}
const performRegister = async (
data: UserCreateRequest
): Promise<UserResponse> => {
const response = await apiClient.post<unknown>(
API_ENDPOINTS.USERS.REGISTER,
data
)
const responseData: unknown = response.data
if (!isValidUserResponse(responseData)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.USERS.REGISTER
)
}
return responseData
}
export const useRegister = (): UseMutationResult<
UserResponse,
Error,
UserCreateRequest
> => {
return useMutation({
mutationFn: performRegister,
onSuccess: (): void => {
toast.success(USER_SUCCESS_MESSAGES.REGISTERED)
},
onError: (error: Error): void => {
const message =
error instanceof UserResponseError
? error.message
: USER_ERROR_MESSAGES.FAILED_TO_CREATE
toast.error(message)
},
})
}
const performUpdateProfile = async (
data: UserUpdateRequest
): Promise<UserResponse> => {
const response = await apiClient.patch<unknown>(API_ENDPOINTS.USERS.ME, data)
const responseData: unknown = response.data
if (!isValidUserResponse(responseData)) {
throw new UserResponseError(
USER_ERROR_MESSAGES.INVALID_USER_RESPONSE,
API_ENDPOINTS.USERS.ME
)
}
return responseData
}
export const useUpdateProfile = (): UseMutationResult<
UserResponse,
Error,
UserUpdateRequest
> => {
const queryClient = useQueryClient()
const updateUser = useAuthStore((s) => s.updateUser)
return useMutation({
mutationFn: performUpdateProfile,
onSuccess: (data: UserResponse): void => {
updateUser(data)
queryClient.setQueryData(authQueries.me(), data)
queryClient.setQueryData(userQueries.me(), data)
toast.success(USER_SUCCESS_MESSAGES.PROFILE_UPDATED)
},
onError: (error: Error): void => {
const message =
error instanceof UserResponseError
? error.message
: USER_ERROR_MESSAGES.FAILED_TO_UPDATE
toast.error(message)
},
})
}

View File

@ -0,0 +1,7 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './hooks'
export * from './types'

View File

@ -0,0 +1,140 @@
// ===================
// © AngelaMos | 2025
// auth.types.ts
// ===================
import { z } from 'zod'
import { PASSWORD_CONSTRAINTS } from '@/config'
export const UserRole = {
UNKNOWN: 'unknown',
USER: 'user',
ADMIN: 'admin',
} as const
export type UserRole = (typeof UserRole)[keyof typeof UserRole]
export const userResponseSchema = z.object({
id: z.string().uuid(),
created_at: z.string().datetime(),
updated_at: z.string().datetime().nullable(),
email: z.string().email(),
full_name: z.string().nullable(),
is_active: z.boolean(),
is_verified: z.boolean(),
role: z.nativeEnum(UserRole),
})
export const tokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
})
export const tokenWithUserResponseSchema = tokenResponseSchema.extend({
user: userResponseSchema,
})
export const loginRequestSchema = z.object({
username: z.string().email(),
password: z
.string()
.min(PASSWORD_CONSTRAINTS.MIN_LENGTH)
.max(PASSWORD_CONSTRAINTS.MAX_LENGTH),
})
export const registerRequestSchema = z.object({
email: z.string().email(),
password: z
.string()
.min(PASSWORD_CONSTRAINTS.MIN_LENGTH)
.max(PASSWORD_CONSTRAINTS.MAX_LENGTH),
full_name: z.string().max(255).optional(),
})
export const passwordChangeRequestSchema = z.object({
current_password: z.string(),
new_password: z
.string()
.min(PASSWORD_CONSTRAINTS.MIN_LENGTH)
.max(PASSWORD_CONSTRAINTS.MAX_LENGTH),
})
export const logoutAllResponseSchema = z.object({
revoked_sessions: z.number(),
})
export type UserResponse = z.infer<typeof userResponseSchema>
export type TokenResponse = z.infer<typeof tokenResponseSchema>
export type TokenWithUserResponse = z.infer<typeof tokenWithUserResponseSchema>
export type LoginRequest = z.infer<typeof loginRequestSchema>
export type RegisterRequest = z.infer<typeof registerRequestSchema>
export type PasswordChangeRequest = z.infer<typeof passwordChangeRequestSchema>
export type LogoutAllResponse = z.infer<typeof logoutAllResponseSchema>
export const isValidUserResponse = (data: unknown): data is UserResponse => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = userResponseSchema.safeParse(data)
return result.success
}
export const isValidTokenResponse = (data: unknown): data is TokenResponse => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = tokenResponseSchema.safeParse(data)
return result.success
}
export const isValidTokenWithUserResponse = (
data: unknown
): data is TokenWithUserResponse => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = tokenWithUserResponseSchema.safeParse(data)
return result.success
}
export const isValidLogoutAllResponse = (
data: unknown
): data is LogoutAllResponse => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = logoutAllResponseSchema.safeParse(data)
return result.success
}
export class AuthResponseError extends Error {
readonly endpoint?: string
constructor(message: string, endpoint?: string) {
super(message)
this.name = 'AuthResponseError'
this.endpoint = endpoint
Object.setPrototypeOf(this, AuthResponseError.prototype)
}
}
export const AUTH_ERROR_MESSAGES = {
INVALID_USER_RESPONSE: 'Invalid user data from server',
INVALID_LOGIN_RESPONSE: 'Invalid login response from server',
INVALID_TOKEN_RESPONSE: 'Invalid token response from server',
INVALID_LOGOUT_RESPONSE: 'Invalid logout response from server',
NO_REFRESH_TOKEN: 'No refresh token available',
SESSION_EXPIRED: 'Session expired',
} as const
export const AUTH_SUCCESS_MESSAGES = {
WELCOME_BACK: (name: string | null) =>
`Welcome back${name !== null ? `, ${name}` : ''}!`,
LOGOUT_SUCCESS: 'Logged out successfully',
PASSWORD_CHANGED: 'Password changed successfully',
REGISTERED: 'Account created successfully!',
} as const
export type AuthErrorMessage =
(typeof AUTH_ERROR_MESSAGES)[keyof typeof AUTH_ERROR_MESSAGES]
export type AuthSuccessMessage = typeof AUTH_SUCCESS_MESSAGES

View File

@ -0,0 +1,7 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './auth.types'
export * from './user.types'

View File

@ -0,0 +1,127 @@
// ===================
// © AngelaMos | 2025
// user.types.ts
// ===================
import { z } from 'zod'
import { PASSWORD_CONSTRAINTS } from '@/config'
import { UserRole, userResponseSchema } from './auth.types'
export { type UserResponse, UserRole, userResponseSchema } from './auth.types'
export const userListResponseSchema = z.object({
items: z.array(userResponseSchema),
total: z.number(),
page: z.number(),
size: z.number(),
})
export const userCreateRequestSchema = z.object({
email: z.string().email(),
password: z
.string()
.min(PASSWORD_CONSTRAINTS.MIN_LENGTH)
.max(PASSWORD_CONSTRAINTS.MAX_LENGTH),
full_name: z.string().max(255).nullable().optional(),
})
export const userUpdateRequestSchema = z.object({
full_name: z.string().max(255).nullable().optional(),
})
export const adminUserCreateRequestSchema = z.object({
email: z.string().email(),
password: z
.string()
.min(PASSWORD_CONSTRAINTS.MIN_LENGTH)
.max(PASSWORD_CONSTRAINTS.MAX_LENGTH),
full_name: z.string().max(255).nullable().optional(),
role: z.nativeEnum(UserRole).optional(),
is_active: z.boolean().optional(),
is_verified: z.boolean().optional(),
})
export const adminUserUpdateRequestSchema = z.object({
email: z.string().email().optional(),
full_name: z.string().max(255).nullable().optional(),
role: z.nativeEnum(UserRole).optional(),
is_active: z.boolean().optional(),
is_verified: z.boolean().optional(),
})
export const paginationParamsSchema = z.object({
page: z.number().min(1),
size: z.number().min(1).max(100),
})
export type UserListResponse = z.infer<typeof userListResponseSchema>
export type UserCreateRequest = z.infer<typeof userCreateRequestSchema>
export type UserUpdateRequest = z.infer<typeof userUpdateRequestSchema>
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>
export type AdminUserUpdateRequest = z.infer<typeof adminUserUpdateRequestSchema>
export type PaginationParams = z.infer<typeof paginationParamsSchema>
export const isValidUserListResponse = (
data: unknown
): data is UserListResponse => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = userListResponseSchema.safeParse(data)
return result.success
}
export const isValidUserCreateRequest = (
data: unknown
): data is UserCreateRequest => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = userCreateRequestSchema.safeParse(data)
return result.success
}
export const isValidAdminUserCreateRequest = (
data: unknown
): data is AdminUserCreateRequest => {
if (data === null || data === undefined) return false
if (typeof data !== 'object') return false
const result = adminUserCreateRequestSchema.safeParse(data)
return result.success
}
export class UserResponseError extends Error {
readonly endpoint?: string
constructor(message: string, endpoint?: string) {
super(message)
this.name = 'UserResponseError'
this.endpoint = endpoint
Object.setPrototypeOf(this, UserResponseError.prototype)
}
}
export const USER_ERROR_MESSAGES = {
INVALID_USER_RESPONSE: 'Invalid user data from server',
INVALID_USER_LIST_RESPONSE: 'Invalid user list from server',
USER_NOT_FOUND: 'User not found',
EMAIL_ALREADY_EXISTS: 'Email already exists',
FAILED_TO_CREATE: 'Failed to create user',
FAILED_TO_UPDATE: 'Failed to update user',
FAILED_TO_DELETE: 'Failed to delete user',
} as const
export const USER_SUCCESS_MESSAGES = {
CREATED: 'User created successfully',
UPDATED: 'User updated successfully',
DELETED: 'User deleted successfully',
PROFILE_UPDATED: 'Profile updated successfully',
REGISTERED:
'Registration successful! Please check your email to verify your account.',
} as const
export type UserErrorMessage =
(typeof USER_ERROR_MESSAGES)[keyof typeof USER_ERROR_MESSAGES]
export type UserSuccessMessage =
(typeof USER_SUCCESS_MESSAGES)[keyof typeof USER_SUCCESS_MESSAGES]

View File

@ -0,0 +1,4 @@
/**
* ©AngelaMos | 2025
* index.tsx
*/

View File

@ -0,0 +1,116 @@
// ===================
// © AngelaMos | 2026
// config.ts
// ===================
const API_VERSION = 'v1'
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `/${API_VERSION}/auth/login`,
REFRESH: `/${API_VERSION}/auth/refresh`,
LOGOUT: `/${API_VERSION}/auth/logout`,
LOGOUT_ALL: `/${API_VERSION}/auth/logout-all`,
ME: `/${API_VERSION}/auth/me`,
CHANGE_PASSWORD: `/${API_VERSION}/auth/change-password`,
},
USERS: {
BASE: `/${API_VERSION}/users`,
BY_ID: (id: string) => `/${API_VERSION}/users/${id}`,
ME: `/${API_VERSION}/users/me`,
REGISTER: `/${API_VERSION}/users`,
},
ADMIN: {
USERS: {
LIST: `/${API_VERSION}/admin/users`,
CREATE: `/${API_VERSION}/admin/users`,
BY_ID: (id: string) => `/${API_VERSION}/admin/users/${id}`,
UPDATE: (id: string) => `/${API_VERSION}/admin/users/${id}`,
DELETE: (id: string) => `/${API_VERSION}/admin/users/${id}`,
},
},
} as const
export const QUERY_KEYS = {
AUTH: {
ALL: ['auth'] as const,
ME: () => [...QUERY_KEYS.AUTH.ALL, 'me'] as const,
},
USERS: {
ALL: ['users'] as const,
BY_ID: (id: string) => [...QUERY_KEYS.USERS.ALL, 'detail', id] as const,
ME: () => [...QUERY_KEYS.USERS.ALL, 'me'] as const,
},
ADMIN: {
ALL: ['admin'] as const,
USERS: {
ALL: () => [...QUERY_KEYS.ADMIN.ALL, 'users'] as const,
LIST: (page: number, size: number) =>
[...QUERY_KEYS.ADMIN.USERS.ALL(), 'list', { page, size }] as const,
BY_ID: (id: string) =>
[...QUERY_KEYS.ADMIN.USERS.ALL(), 'detail', id] as const,
},
},
} as const
export const ROUTES = {
HOME: '/',
LOGIN: '/login',
REGISTER: '/register',
DASHBOARD: '/dashboard',
SETTINGS: '/settings',
UNAUTHORIZED: '/unauthorized',
ADMIN: {
DASHBOARD: '/admin',
USERS: '/admin/users',
USER_DETAIL: (id: string) => `/admin/users/${id}`,
},
} as const
export const STORAGE_KEYS = {
AUTH: 'auth-storage',
UI: 'ui-storage',
} as const
export const QUERY_CONFIG = {
STALE_TIME: {
USER: 1000 * 60 * 5,
STATIC: Infinity,
FREQUENT: 1000 * 30,
},
GC_TIME: {
DEFAULT: 1000 * 60 * 30,
LONG: 1000 * 60 * 60,
},
RETRY: {
DEFAULT: 3,
NONE: 0,
},
} as const
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER: 500,
} as const
export const PASSWORD_CONSTRAINTS = {
MIN_LENGTH: 8,
MAX_LENGTH: 128,
} as const
export const PAGINATION = {
DEFAULT_PAGE: 1,
DEFAULT_SIZE: 20,
MAX_SIZE: 100,
} as const
export type ApiEndpoint = typeof API_ENDPOINTS
export type QueryKey = typeof QUERY_KEYS
export type Route = typeof ROUTES

View File

@ -0,0 +1,160 @@
// ===================
// © AngelaMos | 2025
// api.config.ts
// ===================
import axios, {
type AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from 'axios'
import { API_ENDPOINTS, HTTP_STATUS } from '@/config'
import { useAuthStore } from '@/core/lib'
import { ApiError, ApiErrorCode, transformAxiosError } from './errors'
interface RequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean
}
interface RefreshSubscriber {
resolve: (token: string) => void
reject: (error: Error) => void
}
const getBaseURL = (): string => {
return import.meta.env.VITE_API_URL ?? '/api'
}
export const apiClient: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
})
let isRefreshing = false
let refreshSubscribers: RefreshSubscriber[] = []
const processRefreshQueue = (error: Error | null, token: string | null): void => {
refreshSubscribers.forEach((subscriber) => {
if (error !== null) {
subscriber.reject(error)
} else if (token !== null) {
subscriber.resolve(token)
}
})
refreshSubscribers = []
}
const addRefreshSubscriber = (
resolve: (token: string) => void,
reject: (error: Error) => void
): void => {
refreshSubscribers.push({ resolve, reject })
}
const handleTokenRefresh = async (): Promise<string> => {
const response = await apiClient.post<{ access_token: string }>(
API_ENDPOINTS.AUTH.REFRESH
)
if (
response.data === null ||
response.data === undefined ||
typeof response.data !== 'object'
) {
throw new ApiError(
'Invalid refresh response',
ApiErrorCode.AUTHENTICATION_ERROR,
HTTP_STATUS.UNAUTHORIZED
)
}
const accessToken = response.data.access_token
if (typeof accessToken !== 'string' || accessToken.length === 0) {
throw new ApiError(
'Invalid access token',
ApiErrorCode.AUTHENTICATION_ERROR,
HTTP_STATUS.UNAUTHORIZED
)
}
return accessToken
}
const handleAuthFailure = (): void => {
useAuthStore.getState().logout()
window.location.href = '/login'
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
const token = useAuthStore.getState().accessToken
if (token !== null && token.length > 0) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: unknown): Promise<never> => {
return Promise.reject(error)
}
)
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError): Promise<unknown> => {
const originalRequest = error.config as RequestConfig | undefined
if (originalRequest === undefined) {
return Promise.reject(transformAxiosError(error))
}
const isUnauthorized = error.response?.status === HTTP_STATUS.UNAUTHORIZED
const isNotRetried = originalRequest._retry !== true
const isNotRefreshEndpoint =
originalRequest.url?.includes(API_ENDPOINTS.AUTH.REFRESH) !== true
if (isUnauthorized && isNotRetried && isNotRefreshEndpoint) {
if (isRefreshing) {
return new Promise<unknown>((resolve, reject) => {
addRefreshSubscriber(
(newToken: string): void => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(apiClient(originalRequest))
},
(refreshError: Error): void => {
reject(refreshError)
}
)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const newToken = await handleTokenRefresh()
useAuthStore.getState().setAccessToken(newToken)
processRefreshQueue(null, newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return await apiClient(originalRequest)
} catch (refreshError: unknown) {
const apiError =
refreshError instanceof ApiError
? refreshError
: new ApiError(
'Session expired',
ApiErrorCode.AUTHENTICATION_ERROR,
HTTP_STATUS.UNAUTHORIZED
)
processRefreshQueue(apiError, null)
handleAuthFailure()
return Promise.reject(apiError)
} finally {
isRefreshing = false
}
}
return Promise.reject(transformAxiosError(error))
}
)

View File

@ -0,0 +1,114 @@
/**
* ©AngelaMos | 2025
* errors.ts
*/
import type { AxiosError } from 'axios'
export const ApiErrorCode = {
NETWORK_ERROR: 'NETWORK_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
} as const
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
export class ApiError extends Error {
readonly code: ApiErrorCode
readonly statusCode: number
readonly details?: Record<string, string[]>
constructor(
message: string,
code: ApiErrorCode,
statusCode: number,
details?: Record<string, string[]>
) {
super(message)
this.name = 'ApiError'
this.code = code
this.statusCode = statusCode
this.details = details
}
getUserMessage(): string {
const messages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NETWORK_ERROR]:
'Unable to connect. Please check your internet connection.',
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
[ApiErrorCode.AUTHENTICATION_ERROR]:
'Your session has expired. Please log in again.',
[ApiErrorCode.AUTHORIZATION_ERROR]:
'You do not have permission to perform this action.',
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
[ApiErrorCode.CONFLICT]:
'This operation conflicts with an existing resource.',
[ApiErrorCode.RATE_LIMITED]:
'Too many requests. Please wait a moment and try again.',
[ApiErrorCode.SERVER_ERROR]:
'Something went wrong on our end. Please try again later.',
[ApiErrorCode.UNKNOWN_ERROR]:
'An unexpected error occurred. Please try again.',
}
return messages[this.code]
}
}
interface ApiErrorResponse {
detail?: string | { msg: string; type: string }[]
message?: string
}
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
if (!error.response) {
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
}
const { status } = error.response
const data = error.response.data as ApiErrorResponse | undefined
let message = 'An error occurred'
let details: Record<string, string[]> | undefined
if (data?.detail) {
if (typeof data.detail === 'string') {
message = data.detail
} else if (Array.isArray(data.detail)) {
details = { validation: [] }
data.detail.forEach((err) => {
details?.validation.push(err.msg)
})
message = 'Validation error'
}
} else if (data?.message) {
message = data.message
}
const codeMap: Record<number, ApiErrorCode> = {
400: ApiErrorCode.VALIDATION_ERROR,
401: ApiErrorCode.AUTHENTICATION_ERROR,
403: ApiErrorCode.AUTHORIZATION_ERROR,
404: ApiErrorCode.NOT_FOUND,
409: ApiErrorCode.CONFLICT,
429: ApiErrorCode.RATE_LIMITED,
500: ApiErrorCode.SERVER_ERROR,
502: ApiErrorCode.SERVER_ERROR,
503: ApiErrorCode.SERVER_ERROR,
504: ApiErrorCode.SERVER_ERROR,
}
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
return new ApiError(message, code, status, details)
}
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError
}
}

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './api.config'
export * from './errors'
export * from './query.config'

View File

@ -0,0 +1,105 @@
// ===================
// © AngelaMos | 2025
// query.config.ts
// ===================
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { QUERY_CONFIG } from '@/config'
import { ApiError, ApiErrorCode } from './errors'
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
ApiErrorCode.AUTHENTICATION_ERROR,
ApiErrorCode.AUTHORIZATION_ERROR,
ApiErrorCode.NOT_FOUND,
ApiErrorCode.VALIDATION_ERROR,
] as const
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
if (error instanceof ApiError) {
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
return false
}
}
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
}
const calculateRetryDelay = (attemptIndex: number): number => {
const baseDelay = 1000
const maxDelay = 30000
return Math.min(baseDelay * 2 ** attemptIndex, maxDelay)
}
const handleQueryCacheError = (
error: Error,
query: { state: { data: unknown } }
): void => {
if (query.state.data !== undefined) {
const message =
error instanceof ApiError
? error.getUserMessage()
: 'Background update failed'
toast.error(message)
}
}
const handleMutationCacheError = (
error: Error,
_variables: unknown,
_context: unknown,
mutation: { options: { onError?: unknown } }
): void => {
if (mutation.options.onError === undefined) {
const message =
error instanceof ApiError ? error.getUserMessage() : 'Operation failed'
toast.error(message)
}
}
export const QUERY_STRATEGIES = {
standard: {
staleTime: QUERY_CONFIG.STALE_TIME.USER,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
},
frequent: {
staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT,
},
static: {
staleTime: QUERY_CONFIG.STALE_TIME.STATIC,
gcTime: QUERY_CONFIG.GC_TIME.LONG,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
auth: {
staleTime: QUERY_CONFIG.STALE_TIME.USER,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
retry: QUERY_CONFIG.RETRY.NONE,
},
} as const
export type QueryStrategy = keyof typeof QUERY_STRATEGIES
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: QUERY_CONFIG.STALE_TIME.USER,
gcTime: QUERY_CONFIG.GC_TIME.DEFAULT,
retry: shouldRetryQuery,
retryDelay: calculateRetryDelay,
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchOnReconnect: true,
},
mutations: {
retry: QUERY_CONFIG.RETRY.NONE,
},
},
queryCache: new QueryCache({
onError: handleQueryCacheError,
}),
mutationCache: new MutationCache({
onError: handleMutationCacheError,
}),
})

View File

@ -0,0 +1,47 @@
// ===================
// © AngelaMos | 2025
// protected-route.tsx
// ===================
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import type { UserRole } from '@/api/types'
import { ROUTES } from '@/config'
import { useAuthStore } from '@/core/lib'
interface ProtectedRouteProps {
allowedRoles?: UserRole[]
redirectTo?: string
}
export function ProtectedRoute({
allowedRoles,
redirectTo = ROUTES.LOGIN,
}: ProtectedRouteProps): React.ReactElement {
const location = useLocation()
const { isAuthenticated, isLoading, user } = useAuthStore()
if (isLoading) {
return <div>Loading...</div>
}
if (!isAuthenticated) {
return (
<Navigate
to={redirectTo}
state={{ from: location.pathname + location.search }}
replace
/>
)
}
if (
allowedRoles !== undefined &&
allowedRoles.length > 0 &&
user !== null &&
!allowedRoles.includes(user.role)
) {
return <Navigate to={ROUTES.UNAUTHORIZED} replace />
}
return <Outlet />
}

View File

@ -0,0 +1,67 @@
// ===================
// © AngelaMos | 2025
// routers.tsx
// ===================
import { createBrowserRouter, type RouteObject } from 'react-router-dom'
import { UserRole } from '@/api/types'
import { ROUTES } from '@/config'
import { ProtectedRoute } from './protected-route'
import { Shell } from './shell'
const routes: RouteObject[] = [
{
path: ROUTES.HOME,
lazy: () => import('@/pages/landing'),
},
{
path: ROUTES.LOGIN,
lazy: () => import('@/pages/login'),
},
{
path: ROUTES.REGISTER,
lazy: () => import('@/pages/register'),
},
{
element: <ProtectedRoute />,
children: [
{
element: <Shell />,
children: [
{
path: ROUTES.DASHBOARD,
lazy: () => import('@/pages/dashboard'),
},
{
path: ROUTES.SETTINGS,
lazy: () => import('@/pages/settings'),
},
],
},
],
},
{
element: <ProtectedRoute allowedRoles={[UserRole.ADMIN]} />,
children: [
{
element: <Shell />,
children: [
{
path: ROUTES.ADMIN.USERS,
lazy: () => import('@/pages/admin'),
},
],
},
],
},
{
path: ROUTES.UNAUTHORIZED,
lazy: () => import('@/pages/landing'),
},
{
path: '*',
lazy: () => import('@/pages/landing'),
},
]
export const router = createBrowserRouter(routes)

View File

@ -0,0 +1,331 @@
// ===================
// © AngelaMos | 2025
// shell.module.scss
// ===================
@use '@/styles' as *;
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$header-height: 56px;
.shell {
display: flex;
min-height: 100vh;
min-height: 100dvh;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: $sidebar-width;
background: $bg-surface-100;
border-right: 1px solid $border-default;
display: flex;
flex-direction: column;
z-index: $z-fixed;
@include transition-fast;
&.collapsed {
width: $sidebar-collapsed-width;
}
@include breakpoint-down('sm') {
transform: translateX(-100%);
&.open {
transform: translateX(0);
}
&.collapsed {
width: $sidebar-width;
}
}
}
.sidebarHeader {
height: $header-height;
padding: 0 $space-3;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $border-default;
.sidebar.collapsed & {
justify-content: center;
padding: 0;
}
}
.logo {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: $text-default;
@include transition-fast;
.sidebar.collapsed & {
display: none;
}
}
.nav {
flex: 1;
padding: $space-3;
display: flex;
flex-direction: column;
gap: $space-1;
}
.navItem {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-2 $space-3;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-light;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
&.active {
background: $bg-selection;
color: $text-default;
}
.sidebar.collapsed & {
justify-content: center;
}
}
.navIcon {
width: 17px;
height: 17px;
flex-shrink: 0;
}
.navLabel {
@include transition-fast;
.sidebar.collapsed & {
display: none;
}
}
.adminItem {
margin-top: auto;
border-top: 1px solid $border-default;
padding-top: $space-3;
}
.collapseBtn {
width: 45px;
height: 45px;
border-radius: $radius-md;
color: $text-light;
@include flex-center;
@include transition-fast;
svg {
width: 23.5px;
height: 23.5px;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
}
@include breakpoint-down('sm') {
display: none;
}
}
.sidebarFooter {
padding: $space-3;
border-top: 1px solid $border-default;
}
.logoutBtn {
width: 100%;
display: flex;
align-items: center;
gap: $space-3;
padding: $space-3;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-default;
@include transition-fast;
@include hover {
background: $bg-surface-200;
}
.sidebar.collapsed & {
justify-content: center;
.logoutText {
display: none;
}
}
}
.logoutIcon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.logoutText {
font-weight: $font-weight-medium;
@include transition-fast;
}
.overlay {
position: fixed;
inset: 0;
background: rgb(0, 0, 0, 50%);
z-index: calc($z-fixed - 1);
display: none;
border: none;
padding: 0;
cursor: pointer;
@include breakpoint-down('sm') {
display: block;
}
}
.main {
flex: 1;
display: flex;
flex-direction: column;
margin-left: $sidebar-width;
min-width: 0;
@include transition-fast;
&.collapsed {
margin-left: $sidebar-collapsed-width;
}
@include breakpoint-down('sm') {
margin-left: 0;
&.collapsed {
margin-left: 0;
}
}
}
.header {
position: sticky;
top: 0;
height: $header-height;
background: $bg-surface-100;
border-bottom: 1px solid $border-default;
z-index: $z-sticky;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $space-4;
}
.headerLeft {
display: flex;
align-items: center;
gap: $space-3;
}
.menuBtn {
display: none;
width: 36px;
height: 36px;
border-radius: $radius-md;
color: $text-light;
align-items: center;
justify-content: center;
@include transition-fast;
svg {
width: 20px;
height: 20px;
}
@include hover {
background: $bg-surface-200;
color: $text-default;
}
@media (width <= 479px) {
display: flex;
}
}
.pageTitle {
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $text-default;
margin-left: 7px;
}
.headerRight {
display: flex;
align-items: center;
gap: $space-3;
}
.avatar {
width: 32px;
height: 32px;
border-radius: $radius-full;
background: $bg-surface-300;
color: $text-light;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
@include flex-center;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(1.2);
}
}
.content {
flex: 1;
overflow-y: auto;
}
.loading {
@include flex-center;
height: 100%;
color: $text-muted;
}
.error {
@include flex-column-center;
height: 100%;
gap: $space-4;
padding: $space-6;
color: $error-default;
h2 {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
}
pre {
font-family: $font-mono;
font-size: $font-size-sm;
padding: $space-4;
background: $bg-surface-200;
border-radius: $radius-lg;
overflow-x: auto;
max-width: 100%;
}
}

View File

@ -0,0 +1,160 @@
/**
* ©AngelaMos | 2025
* shell.tsx
*/
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { GiCardAceClubs, GiCardJoker, GiExitDoor } from 'react-icons/gi'
import { LuChevronLeft, LuChevronRight, LuMenu, LuShield } from 'react-icons/lu'
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'
import { useLogout } from '@/api/hooks'
import { ROUTES } from '@/config'
import { useIsAdmin, useUIStore, useUser } from '@/core/lib'
import styles from './shell.module.scss'
const NAV_ITEMS = [
{ path: ROUTES.DASHBOARD, label: 'Dashboard', icon: GiCardJoker },
{ path: ROUTES.SETTINGS, label: 'Settings', icon: GiCardAceClubs },
]
const ADMIN_NAV_ITEM = {
path: ROUTES.ADMIN.USERS,
label: 'Admin',
icon: LuShield,
}
function ShellErrorFallback({ error }: { error: Error }): React.ReactElement {
return (
<div className={styles.error}>
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</div>
)
}
function ShellLoading(): React.ReactElement {
return <div className={styles.loading}>Loading...</div>
}
function getPageTitle(pathname: string, isAdmin: boolean): string {
if (isAdmin && pathname === ADMIN_NAV_ITEM.path) {
return ADMIN_NAV_ITEM.label
}
const item = NAV_ITEMS.find((i) => i.path === pathname)
return item?.label ?? 'Dashboard'
}
export function Shell(): React.ReactElement {
const location = useLocation()
const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } =
useUIStore()
const { mutate: logout } = useLogout()
const isAdmin = useIsAdmin()
const user = useUser()
const pageTitle = getPageTitle(location.pathname, isAdmin)
const avatarLetter =
user?.full_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U'
return (
<div className={styles.shell}>
<aside
className={`${styles.sidebar} ${sidebarOpen ? styles.open : ''} ${sidebarCollapsed ? styles.collapsed : ''}`}
>
<div className={styles.sidebarHeader}>
<span className={styles.logo}>NavBar Template</span>
<button
type="button"
className={styles.collapseBtn}
onClick={toggleSidebarCollapsed}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? <LuChevronRight /> : <LuChevronLeft />}
</button>
</div>
<nav className={styles.nav}>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.active : ''}`
}
onClick={() => sidebarOpen && toggleSidebar()}
>
<item.icon className={styles.navIcon} />
<span className={styles.navLabel}>{item.label}</span>
</NavLink>
))}
{isAdmin && (
<NavLink
to={ADMIN_NAV_ITEM.path}
className={({ isActive }) =>
`${styles.navItem} ${styles.adminItem} ${isActive ? styles.active : ''}`
}
onClick={() => sidebarOpen && toggleSidebar()}
>
<ADMIN_NAV_ITEM.icon className={styles.navIcon} />
<span className={styles.navLabel}>{ADMIN_NAV_ITEM.label}</span>
</NavLink>
)}
</nav>
<div className={styles.sidebarFooter}>
<button
type="button"
className={styles.logoutBtn}
onClick={() => logout()}
>
<GiExitDoor className={styles.logoutIcon} />
<span className={styles.logoutText}>Logout</span>
</button>
</div>
</aside>
{sidebarOpen && (
<button
type="button"
className={styles.overlay}
onClick={toggleSidebar}
onKeyDown={(e) => e.key === 'Escape' && toggleSidebar()}
aria-label="Close sidebar"
/>
)}
<div
className={`${styles.main} ${sidebarCollapsed ? styles.collapsed : ''}`}
>
<header className={styles.header}>
<div className={styles.headerLeft}>
<button
type="button"
className={styles.menuBtn}
onClick={toggleSidebar}
aria-label="Toggle menu"
>
<LuMenu />
</button>
<h1 className={styles.pageTitle}>{pageTitle}</h1>
</div>
<div className={styles.headerRight}>
<Link to={ROUTES.SETTINGS} className={styles.avatar}>
{avatarLetter}
</Link>
</div>
</header>
<main className={styles.content}>
<ErrorBoundary FallbackComponent={ShellErrorFallback}>
<Suspense fallback={<ShellLoading />}>
<Outlet />
</Suspense>
</ErrorBoundary>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,67 @@
// ===================
// © AngelaMos | 2025
// toast.module.scss
// ===================
@use '@/styles' as *;
:global {
[data-sonner-toaster] {
--normal-bg: #{$bg-surface-100};
--normal-border: #{$border-default};
--normal-text: #{$text-default};
--success-bg: #{$bg-surface-100};
--success-border: #{$border-default};
--success-text: #{$text-default};
--error-bg: #{$bg-surface-100};
--error-border: #{$error-default};
--error-text: #{$text-default};
--warning-bg: #{$bg-surface-100};
--warning-border: #{$border-default};
--warning-text: #{$text-default};
--info-bg: #{$bg-surface-100};
--info-border: #{$border-default};
--info-text: #{$text-default};
font-family: $font-sans;
}
[data-sonner-toast] {
border-radius: $radius-md;
padding: $space-3 $space-4;
font-size: $font-size-sm;
border: 1px solid $border-default;
background: $bg-surface-100;
color: $text-default;
[data-title] {
font-weight: $font-weight-medium;
}
[data-description] {
color: $text-light;
font-size: $font-size-xs;
}
[data-close-button] {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: $text-muted;
@include transition-fast;
@include hover {
color: $text-default;
}
}
}
[data-sonner-toast][data-type='error'] {
border-color: $error-default;
}
}

View File

@ -0,0 +1,43 @@
/**
* ©AngelaMos | 2025
* auth.form.store.ts
*/
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface AuthFormState {
loginEmail: string
registerEmail: string
setLoginEmail: (email: string) => void
setRegisterEmail: (email: string) => void
clearLoginForm: () => void
clearRegisterForm: () => void
}
export const useAuthFormStore = create<AuthFormState>()(
devtools(
persist(
(set) => ({
loginEmail: '',
registerEmail: '',
setLoginEmail: (email) =>
set({ loginEmail: email }, false, 'authForm/setLoginEmail'),
setRegisterEmail: (email) =>
set({ registerEmail: email }, false, 'authForm/setRegisterEmail'),
clearLoginForm: () =>
set({ loginEmail: '' }, false, 'authForm/clearLoginForm'),
clearRegisterForm: () =>
set({ registerEmail: '' }, false, 'authForm/clearRegisterForm'),
}),
{
name: 'auth-form-storage',
}
),
{ name: 'AuthFormStore' }
)
)

View File

@ -0,0 +1,105 @@
// ===================
// © AngelaMos | 2025
// auth.store.ts
// ===================
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { type UserResponse, UserRole } from '@/api/types'
import { STORAGE_KEYS } from '@/config'
interface AuthState {
user: UserResponse | null
accessToken: string | null
isAuthenticated: boolean
isLoading: boolean
}
interface AuthActions {
login: (user: UserResponse, accessToken: string) => void
logout: () => void
setLoading: (loading: boolean) => void
setAccessToken: (token: string | null) => void
updateUser: (updates: Partial<UserResponse>) => void
}
type AuthStore = AuthState & AuthActions
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
(set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false,
login: (user, accessToken) =>
set(
{
user,
accessToken,
isAuthenticated: true,
isLoading: false,
},
false,
'auth/login'
),
logout: () =>
set(
{
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false,
},
false,
'auth/logout'
),
setLoading: (loading) =>
set({ isLoading: loading }, false, 'auth/setLoading'),
setAccessToken: (token) =>
set({ accessToken: token }, false, 'auth/setAccessToken'),
updateUser: (updates) =>
set(
(state) => ({
user: state.user !== null ? { ...state.user, ...updates } : null,
}),
false,
'auth/updateUser'
),
}),
{
name: STORAGE_KEYS.AUTH,
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
),
{ name: 'AuthStore' }
)
)
export const useUser = (): UserResponse | null => useAuthStore((s) => s.user)
export const useIsAuthenticated = (): boolean =>
useAuthStore((s) => s.isAuthenticated)
export const useIsAuthLoading = (): boolean => useAuthStore((s) => s.isLoading)
export const useAccessToken = (): string | null =>
useAuthStore((s) => s.accessToken)
export const useHasRole = (role: UserRole): boolean => {
const user = useAuthStore((s) => s.user)
return user !== null && user.role === role
}
export const useIsAdmin = (): boolean => {
const user = useAuthStore((s) => s.user)
return user !== null && user.role === UserRole.ADMIN
}
export { UserRole }

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// index.ts
// ===================
export * from './auth.form.store'
export * from './auth.store'
export * from './shell.ui.store'

View File

@ -0,0 +1,63 @@
/**
* ©AngelaMos | 2025
* ui.store.ts
*/
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
type Theme = 'light' | 'dark' | 'system'
interface UIState {
theme: Theme
sidebarOpen: boolean
sidebarCollapsed: boolean
setTheme: (theme: Theme) => void
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
toggleSidebarCollapsed: () => void
}
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
theme: 'dark',
sidebarOpen: false,
sidebarCollapsed: false,
setTheme: (theme) => set({ theme }, false, 'ui/setTheme'),
toggleSidebar: () =>
set(
(state) => ({ sidebarOpen: !state.sidebarOpen }),
false,
'ui/toggleSidebar'
),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'),
toggleSidebarCollapsed: () =>
set(
(state) => ({ sidebarCollapsed: !state.sidebarCollapsed }),
false,
'ui/toggleSidebarCollapsed'
),
}),
{
name: 'ui-storage',
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
}),
}
),
{ name: 'UIStore' }
)
)
export const useTheme = (): Theme => useUIStore((s) => s.theme)
export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen)
export const useSidebarCollapsed = (): boolean =>
useUIStore((s) => s.sidebarCollapsed)

View File

@ -0,0 +1,15 @@
// ===========================
// ©AngelaMos | 2025
// main.tsx
// ===========================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.scss'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,468 @@
// ===================
// © AngelaMos | 2025
// admin.module.scss
// ===================
@use '@/styles' as *;
.page {
padding: $space-6;
min-height: calc(100vh - 56px);
background-color: $bg-default;
}
.header {
@include flex-between;
margin-bottom: $space-6;
}
.title {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $text-default;
}
.createBtn {
display: flex;
align-items: center;
gap: $space-2;
padding: $space-2 $space-4;
background-color: $white;
border: none;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $black;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(0.9);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
}
}
.table {
width: 100%;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-lg;
overflow: hidden;
}
.tableHeader {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
gap: $space-4;
padding: $space-3 $space-4;
background: $bg-surface-200;
border-bottom: 1px solid $border-default;
@include breakpoint-down('md') {
display: none;
}
}
.tableHeaderCell {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-lighter;
text-transform: uppercase;
letter-spacing: $tracking-wide;
}
.tableBody {
@include flex-column;
}
.tableRow {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 100px;
gap: $space-4;
padding: $space-3 $space-4;
border-bottom: 1px solid $border-default;
@include transition-fast;
&:last-child {
border-bottom: none;
}
@include hover {
background: $bg-surface-75;
}
@include breakpoint-down('md') {
grid-template-columns: 1fr;
gap: $space-2;
}
}
.tableCell {
display: flex;
align-items: center;
font-size: $font-size-sm;
color: $text-default;
min-width: 0;
@include breakpoint-down('md') {
&::before {
content: attr(data-label);
font-size: $font-size-xs;
color: $text-lighter;
margin-right: $space-2;
min-width: 80px;
}
}
}
.email {
@include truncate;
}
.badge {
display: inline-flex;
align-items: center;
padding: $space-1 $space-2;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
&.admin {
background: $bg-selection;
color: $text-default;
}
&.user {
background: $bg-surface-200;
color: $text-light;
}
&.active {
background: hsl(142 76% 36% / 20%);
color: hsl(142, 76%, 46%);
}
&.inactive {
background: hsl(0 72% 51% / 20%);
color: $error-light;
}
}
.actions {
display: flex;
gap: $space-2;
justify-content: flex-end;
@include breakpoint-down('md') {
justify-content: flex-start;
}
}
.actionBtn {
width: 32px;
height: 32px;
@include flex-center;
border: 1px solid $border-default;
border-radius: $radius-md;
background: transparent;
color: $text-light;
cursor: pointer;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
&.delete {
@include hover {
border-color: $error-default;
color: $error-default;
}
}
svg {
width: 16px;
height: 16px;
}
}
.pagination {
@include flex-between;
padding: $space-4;
border-top: 1px solid $border-default;
}
.paginationInfo {
font-size: $font-size-sm;
color: $text-lighter;
}
.paginationBtns {
display: flex;
gap: $space-2;
}
.paginationBtn {
padding: $space-2 $space-3;
border: 1px solid $border-default;
border-radius: $radius-md;
background: transparent;
font-size: $font-size-sm;
color: $text-light;
cursor: pointer;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.empty {
@include flex-column-center;
padding: $space-12;
color: $text-muted;
font-size: $font-size-sm;
}
.loading {
@include flex-center;
padding: $space-12;
color: $text-muted;
}
.modal {
position: fixed;
inset: 0;
z-index: $z-modal;
@include flex-center;
}
.modalOverlay {
@include absolute-fill;
background: rgb(0, 0, 0, 70%);
}
.modalContent {
position: relative;
width: 100%;
max-width: 400px;
margin: $space-4;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-lg;
padding: $space-6;
}
.modalHeader {
@include flex-between;
margin-bottom: $space-5;
}
.modalTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-default;
}
.modalClose {
width: 32px;
height: 32px;
@include flex-center;
border: none;
border-radius: $radius-md;
background: transparent;
color: $text-light;
cursor: pointer;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
svg {
width: 20px;
height: 20px;
}
}
.form {
@include flex-column;
gap: $space-4;
}
.field {
@include flex-column;
gap: $space-2;
}
.label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-default;
}
.input {
width: 100%;
height: 44px;
padding: 0 $space-3;
background: transparent;
border: 1px solid $border-default;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-default;
@include transition-fast;
&::placeholder {
color: $text-muted;
}
&:focus {
outline: none;
border-color: $border-strong;
}
}
.select {
width: 100%;
height: 44px;
padding: 0 $space-3;
background: transparent;
border: 1px solid $border-default;
border-radius: $radius-md;
font-size: $font-size-sm;
color: $text-default;
cursor: pointer;
@include transition-fast;
&:focus {
outline: none;
border-color: $border-strong;
}
option {
background: $bg-surface-100;
color: $text-default;
}
}
.checkbox {
display: flex;
align-items: center;
gap: $space-2;
cursor: pointer;
input {
width: 18px;
height: 18px;
accent-color: $white;
}
span {
font-size: $font-size-sm;
color: $text-light;
}
}
.formActions {
display: flex;
gap: $space-3;
margin-top: $space-2;
}
.submitBtn {
flex: 1;
height: 44px;
@include flex-center;
background: $white;
border: none;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $black;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(0.9);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.cancelBtn {
flex: 1;
height: 44px;
@include flex-center;
background: transparent;
border: 1px solid $border-default;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-light;
cursor: pointer;
@include transition-fast;
@include hover {
background: $bg-surface-200;
color: $text-default;
}
}
.deleteConfirm {
@include flex-column;
gap: $space-4;
}
.deleteText {
font-size: $font-size-sm;
color: $text-light;
line-height: $line-height-relaxed;
}
.deleteEmail {
font-weight: $font-weight-medium;
color: $text-default;
}
.deleteBtn {
flex: 1;
height: 44px;
@include flex-center;
background: $error-default;
border: none;
border-radius: $radius-md;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $white;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(0.9);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@ -0,0 +1,415 @@
/**
* ©AngelaMos | 2025
* index.tsx
*/
import { useState } from 'react'
import { LuPencil, LuPlus, LuTrash2, LuX } from 'react-icons/lu'
import {
useAdminCreateUser,
useAdminDeleteUser,
useAdminUpdateUser,
useAdminUsers,
} from '@/api/hooks'
import type { UserResponse } from '@/api/types'
import { UserRole } from '@/api/types'
import { PAGINATION } from '@/config'
import styles from './admin.module.scss'
type ModalState =
| { type: 'closed' }
| { type: 'create' }
| { type: 'edit'; user: UserResponse }
| { type: 'delete'; user: UserResponse }
export function Component(): React.ReactElement {
const [page, setPage] = useState<number>(PAGINATION.DEFAULT_PAGE)
const [modal, setModal] = useState<ModalState>({ type: 'closed' })
const { data, isLoading } = useAdminUsers({
page,
size: PAGINATION.DEFAULT_SIZE,
})
const createUser = useAdminCreateUser()
const updateUser = useAdminUpdateUser()
const deleteUser = useAdminDeleteUser()
const handleCreate = (formData: FormData): void => {
const email = formData.get('email') as string
const password = formData.get('password') as string
const fullName = (formData.get('fullName') as string) || undefined
const role = formData.get('role') as UserRole
const isActive = formData.get('isActive') === 'on'
createUser.mutate(
{ email, password, full_name: fullName, role, is_active: isActive },
{ onSuccess: () => setModal({ type: 'closed' }) }
)
}
const handleUpdate = (userId: string, formData: FormData): void => {
const email = formData.get('email') as string
const fullName = (formData.get('fullName') as string) || undefined
const role = formData.get('role') as UserRole
const isActive = formData.get('isActive') === 'on'
updateUser.mutate(
{
id: userId,
data: { email, full_name: fullName, role, is_active: isActive },
},
{ onSuccess: () => setModal({ type: 'closed' }) }
)
}
const handleDelete = (userId: string): void => {
deleteUser.mutate(userId, { onSuccess: () => setModal({ type: 'closed' }) })
}
const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}>Users</h1>
<button
type="button"
className={styles.createBtn}
onClick={() => setModal({ type: 'create' })}
>
<LuPlus />
Create User
</button>
</div>
<div className={styles.table}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderCell}>Email</div>
<div className={styles.tableHeaderCell}>Name</div>
<div className={styles.tableHeaderCell}>Role</div>
<div className={styles.tableHeaderCell}>Status</div>
<div className={styles.tableHeaderCell}>Actions</div>
</div>
<div className={styles.tableBody}>
{isLoading && <div className={styles.loading}>Loading...</div>}
{!isLoading && data?.items.length === 0 && (
<div className={styles.empty}>No users found</div>
)}
{data?.items.map((user) => (
<div key={user.id} className={styles.tableRow}>
<div className={styles.tableCell} data-label="Email">
<span className={styles.email}>{user.email}</span>
</div>
<div className={styles.tableCell} data-label="Name">
{user.full_name ?? '—'}
</div>
<div className={styles.tableCell} data-label="Role">
<span
className={`${styles.badge} ${user.role === UserRole.ADMIN ? styles.admin : styles.user}`}
>
{user.role}
</span>
</div>
<div className={styles.tableCell} data-label="Status">
<span
className={`${styles.badge} ${user.is_active ? styles.active : styles.inactive}`}
>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.actionBtn}
onClick={() => setModal({ type: 'edit', user })}
aria-label="Edit user"
>
<LuPencil />
</button>
<button
type="button"
className={`${styles.actionBtn} ${styles.delete}`}
onClick={() => setModal({ type: 'delete', user })}
aria-label="Delete user"
>
<LuTrash2 />
</button>
</div>
</div>
))}
</div>
{data && data.total > PAGINATION.DEFAULT_SIZE && (
<div className={styles.pagination}>
<span className={styles.paginationInfo}>
Page {page} of {totalPages} ({data.total} users)
</span>
<div className={styles.paginationBtns}>
<button
type="button"
className={styles.paginationBtn}
onClick={() => setPage((p) => p - 1)}
disabled={page <= 1}
>
Previous
</button>
<button
type="button"
className={styles.paginationBtn}
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages}
>
Next
</button>
</div>
</div>
)}
</div>
{modal.type === 'create' && (
<div className={styles.modal}>
<button
type="button"
className={styles.modalOverlay}
onClick={() => setModal({ type: 'closed' })}
onKeyDown={(e) => e.key === 'Escape' && setModal({ type: 'closed' })}
aria-label="Close modal"
/>
<div className={styles.modalContent}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Create User</h2>
<button
type="button"
className={styles.modalClose}
onClick={() => setModal({ type: 'closed' })}
>
<LuX />
</button>
</div>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault()
handleCreate(new FormData(e.currentTarget))
}}
>
<div className={styles.field}>
<label className={styles.label} htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
className={styles.input}
required
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
className={styles.input}
required
minLength={8}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="fullName">
Full Name
</label>
<input
id="fullName"
name="fullName"
type="text"
className={styles.input}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="role">
Role
</label>
<select id="role" name="role" className={styles.select}>
<option value={UserRole.USER}>User</option>
<option value={UserRole.ADMIN}>Admin</option>
</select>
</div>
<label className={styles.checkbox}>
<input type="checkbox" name="isActive" defaultChecked />
<span>Active</span>
</label>
<div className={styles.formActions}>
<button
type="button"
className={styles.cancelBtn}
onClick={() => setModal({ type: 'closed' })}
>
Cancel
</button>
<button
type="submit"
className={styles.submitBtn}
disabled={createUser.isPending}
>
{createUser.isPending ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{modal.type === 'edit' && (
<div className={styles.modal}>
<button
type="button"
className={styles.modalOverlay}
onClick={() => setModal({ type: 'closed' })}
onKeyDown={(e) => e.key === 'Escape' && setModal({ type: 'closed' })}
aria-label="Close modal"
/>
<div className={styles.modalContent}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Edit User</h2>
<button
type="button"
className={styles.modalClose}
onClick={() => setModal({ type: 'closed' })}
>
<LuX />
</button>
</div>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault()
handleUpdate(modal.user.id, new FormData(e.currentTarget))
}}
>
<div className={styles.field}>
<label className={styles.label} htmlFor="editEmail">
Email
</label>
<input
id="editEmail"
name="email"
type="email"
className={styles.input}
defaultValue={modal.user.email}
required
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="editFullName">
Full Name
</label>
<input
id="editFullName"
name="fullName"
type="text"
className={styles.input}
defaultValue={modal.user.full_name ?? ''}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="editRole">
Role
</label>
<select
id="editRole"
name="role"
className={styles.select}
defaultValue={modal.user.role}
>
<option value={UserRole.USER}>User</option>
<option value={UserRole.ADMIN}>Admin</option>
</select>
</div>
<label className={styles.checkbox}>
<input
type="checkbox"
name="isActive"
defaultChecked={modal.user.is_active}
/>
<span>Active</span>
</label>
<div className={styles.formActions}>
<button
type="button"
className={styles.cancelBtn}
onClick={() => setModal({ type: 'closed' })}
>
Cancel
</button>
<button
type="submit"
className={styles.submitBtn}
disabled={updateUser.isPending}
>
{updateUser.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
)}
{modal.type === 'delete' && (
<div className={styles.modal}>
<button
type="button"
className={styles.modalOverlay}
onClick={() => setModal({ type: 'closed' })}
onKeyDown={(e) => e.key === 'Escape' && setModal({ type: 'closed' })}
aria-label="Close modal"
/>
<div className={styles.modalContent}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Delete User</h2>
<button
type="button"
className={styles.modalClose}
onClick={() => setModal({ type: 'closed' })}
>
<LuX />
</button>
</div>
<div className={styles.deleteConfirm}>
<p className={styles.deleteText}>
Are you sure you want to delete{' '}
<span className={styles.deleteEmail}>{modal.user.email}</span>?
This action cannot be undone.
</p>
<div className={styles.formActions}>
<button
type="button"
className={styles.cancelBtn}
onClick={() => setModal({ type: 'closed' })}
>
Cancel
</button>
<button
type="button"
className={styles.deleteBtn}
onClick={() => handleDelete(modal.user.id)}
disabled={deleteUser.isPending}
>
{deleteUser.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
Component.displayName = 'AdminUsers'

View File

@ -0,0 +1,152 @@
// ===================
// © AngelaMos | 2025
// dashboard.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: calc(100vh - 56px);
padding: $space-6;
background-color: $bg-default;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: $space-6;
}
.title {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $text-default;
margin-bottom: $space-2;
}
.subtitle {
font-size: $font-size-sm;
color: $text-lighter;
}
.userCard {
display: flex;
align-items: center;
gap: $space-4;
padding: $space-5;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-lg;
margin-bottom: $space-8;
}
.avatar {
width: 56px;
height: 56px;
border-radius: $radius-full;
background: $bg-surface-300;
color: $text-default;
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
@include flex-center;
flex-shrink: 0;
}
.userInfo {
@include flex-column;
gap: $space-1;
min-width: 0;
}
.userName {
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $text-default;
@include truncate;
}
.userEmail {
font-size: $font-size-sm;
color: $text-light;
@include truncate;
}
.userRole {
display: inline-flex;
align-self: flex-start;
padding: $space-0-5 $space-2;
background: $bg-surface-200;
border-radius: $radius-full;
font-size: $font-size-xs;
color: $text-lighter;
text-transform: uppercase;
letter-spacing: $tracking-wide;
}
.section {
margin-bottom: $space-8;
}
.sectionTitle {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-light;
text-transform: uppercase;
letter-spacing: $tracking-wide;
margin-bottom: $space-4;
}
.grid {
display: grid;
gap: $space-4;
@include breakpoint-up('md') {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
padding: $space-4;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-lg;
}
.hookName {
display: inline-block;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-default;
background: $bg-surface-200;
padding: $space-1 $space-2;
border-radius: $radius-sm;
margin-bottom: $space-2;
}
.description {
font-size: $font-size-sm;
color: $text-light;
margin-bottom: $space-2;
line-height: $line-height-relaxed;
}
.file {
font-size: $font-size-xs;
color: $text-muted;
font-family: $font-mono;
}
.list {
@include flex-column;
gap: $space-2;
padding-left: $space-5;
list-style: disc;
li {
font-size: $font-size-sm;
color: $text-light;
}
}

View File

@ -0,0 +1,90 @@
/**
* ©AngelaMos | 2025
* index.tsx
*/
import { useUser } from '@/core/lib'
import styles from './dashboard.module.scss'
const AVAILABLE_STORES = [
{
name: 'useUser()',
file: 'core/lib/auth.store.ts',
description: 'Get current authenticated user',
},
{
name: 'useIsAuthenticated()',
file: 'core/lib/auth.store.ts',
description: 'Check if user is logged in',
},
{
name: 'useIsAdmin()',
file: 'core/lib/auth.store.ts',
description: 'Check if user has admin role',
},
]
const SUGGESTED_FEATURES = [
'User stats and metrics',
'Recent activity feed',
'Quick actions',
'Charts and analytics',
'Notifications overview',
'Task/project summary',
]
export function Component(): React.ReactElement {
const user = useUser()
return (
<div className={styles.page}>
<div className={styles.container}>
<div className={styles.header}>
<h1 className={styles.title}>
Welcome{user?.full_name ? `, ${user.full_name}` : ''}
</h1>
<p className={styles.subtitle}>
Template page build your dashboard here
</p>
</div>
<div className={styles.userCard}>
<div className={styles.avatar}>
{user?.full_name?.[0]?.toUpperCase() ??
user?.email?.[0]?.toUpperCase() ??
'U'}
</div>
<div className={styles.userInfo}>
<span className={styles.userName}>{user?.full_name ?? 'User'}</span>
<span className={styles.userEmail}>{user?.email}</span>
<span className={styles.userRole}>{user?.role}</span>
</div>
</div>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Available Stores</h2>
<div className={styles.grid}>
{AVAILABLE_STORES.map((store) => (
<div key={store.name} className={styles.card}>
<code className={styles.hookName}>{store.name}</code>
<p className={styles.description}>{store.description}</p>
<span className={styles.file}>{store.file}</span>
</div>
))}
</div>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Suggested Features</h2>
<ul className={styles.list}>
{SUGGESTED_FEATURES.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
</section>
</div>
</div>
)
}
Component.displayName = 'Dashboard'

View File

@ -0,0 +1,97 @@
/**
* ©AngelaMos | 2026
* index.tsx
*/
import { FiGithub } from 'react-icons/fi'
import { Link } from 'react-router-dom'
import { ROUTES } from '@/config'
import styles from './landing.module.scss'
export function Component(): React.ReactElement {
return (
<div className={styles.page}>
<header className={styles.header}>
<h1 className={styles.title}>Full Stack Template</h1>
<p className={styles.subtitle}>by Carter Perez</p>
<a
href="https://github.com/CarterPerez-dev/fullstack-template"
target="_blank"
rel="noopener noreferrer"
className={styles.github}
aria-label="View source on GitHub"
>
<FiGithub />
</a>
</header>
<div className={styles.content}>
<p className={styles.description}>
Boilerplate for medium-large scale applications. Built with modern
patterns, strict typing, and security best practices.
</p>
<div className={styles.sections}>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Frontend</h2>
<ul className={styles.features}>
<li>React 19 + TypeScript with strict mode</li>
<li>TanStack Query for server state caching</li>
<li>Zustand stores with persistence</li>
<li>Axios interceptors with auto token refresh</li>
<li>Zod runtime validation on API responses</li>
<li>SCSS modules with design tokens</li>
</ul>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Backend</h2>
<ul className={styles.features}>
<li>DDD + DI Architecture</li>
<li>FastAPI with async/await throughout</li>
<li>SQLAlchemy 2.0+ async with connection pooling</li>
<li>JWT auth with token rotation and replay detection</li>
<li>Argon2id hashing with timing safe verification</li>
<li>Pydantic v2 strict validation</li>
</ul>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Infrastructure</h2>
<ul className={styles.features}>
<li>Docker Compose with dev/prod configs</li>
<li>Nginx reverse proxy with rate limiting</li>
<li>PostgreSQL 18 + Redis 7</li>
<li>Health checks and graceful shutdown</li>
</ul>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>DevOps</h2>
<ul className={styles.features}>
<li>GitHub Actions CI (Ruff, Pylint, Mypy, Biome)</li>
<li>Strict type checking on both ends</li>
<li>Alembic async migrations</li>
</ul>
</section>
</div>
<div className={styles.actions}>
<Link to={ROUTES.REGISTER} className={styles.button}>
Try Auth Flow
</Link>
<a
href="/api/docs"
target="_blank"
rel="noopener noreferrer"
className={styles.buttonOutline}
>
API Docs
</a>
</div>
</div>
</div>
)
}
Component.displayName = 'Landing'

View File

@ -0,0 +1,161 @@
// ===================
// © AngelaMos | 2025
// landing.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: 100vh;
min-height: 100dvh;
@include flex-column-center;
background-color: $bg-default;
background-image: radial-gradient(
circle,
$bg-landing 1px,
transparent 1px
);
background-size: 20px 20px;
padding: $space-8;
}
.header {
text-align: center;
margin-bottom: $space-5;
}
.title {
font-size: $font-size-4xl;
font-weight: $font-weight-semibold;
color: $text-default;
letter-spacing: $tracking-tight;
margin-bottom: $space-2;
}
.subtitle {
font-size: $font-size-lg;
color: $text-light;
margin-bottom: $space-3;
}
.github {
display: inline-flex;
color: $text-muted;
font-size: $font-size-2xl;
@include transition-fast;
@include hover {
&:hover {
color: $text-default;
}
}
}
.content {
max-width: 800px;
text-align: center;
}
.description {
font-size: $font-size-base;
color: $text-light;
line-height: $line-height-relaxed;
margin-bottom: $space-8;
}
.sections {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $space-6;
margin-bottom: $space-10;
text-align: left;
@include breakpoint-down(md) {
grid-template-columns: 1fr;
}
}
.section {
padding: $space-5;
background-color: $bg-surface-75;
border: 1px solid $border-muted;
border-radius: $radius-lg;
}
.sectionTitle {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-default;
margin-bottom: $space-3;
letter-spacing: $tracking-wide;
text-transform: uppercase;
}
.features {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: $space-2;
li {
font-size: $font-size-sm;
color: $text-muted;
&::before {
content: '';
margin-right: $space-2;
color: $text-lighter;
}
}
}
.actions {
display: flex;
gap: $space-3;
justify-content: center;
flex-wrap: wrap;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $space-3 $space-5;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $bg-default;
background-color: $text-default;
border-radius: $radius-md;
text-decoration: none;
@include transition-fast;
@include hover {
&:hover {
filter: brightness(0.9);
}
}
}
.buttonOutline {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $space-3 $space-5;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-default;
background-color: transparent;
border: 1px solid $border-default;
border-radius: $radius-md;
text-decoration: none;
@include transition-fast;
@include hover {
&:hover {
border-color: $border-strong;
background-color: $bg-surface-75;
}
}
}

View File

@ -0,0 +1,114 @@
/**
* ©AngelaMos | 2025
* index.tsx
*/
import { useState } from 'react'
import { LuEye, LuEyeOff } from 'react-icons/lu'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useLogin } from '@/api/hooks'
import { loginRequestSchema } from '@/api/types'
import { ROUTES } from '@/config'
import { useAuthFormStore } from '@/core/lib'
import styles from './login.module.scss'
export function Component(): React.ReactElement {
const navigate = useNavigate()
const login = useLogin()
const { loginEmail, setLoginEmail, clearLoginForm } = useAuthFormStore()
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const handleSubmit = (e: React.FormEvent): void => {
e.preventDefault()
const result = loginRequestSchema.safeParse({
username: loginEmail,
password,
})
if (!result.success) {
const firstError = result.error.issues[0]
toast.error(firstError.message)
return
}
login.mutate(result.data, {
onSuccess: () => {
clearLoginForm()
navigate(ROUTES.DASHBOARD)
},
})
}
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.header}>
<h1 className={styles.title}>Login</h1>
<p className={styles.subtitle}>Welcome back</p>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label} htmlFor="email">
Email
</label>
<input
id="email"
type="email"
className={styles.input}
placeholder="xxx@example.com"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
autoComplete="email"
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="password">
Password
</label>
<div className={styles.inputWrapper}>
<input
id="password"
type={showPassword ? 'text' : 'password'}
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<button
type="button"
className={styles.eyeButton}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <LuEyeOff /> : <LuEye />}
</button>
</div>
</div>
<button
type="submit"
className={styles.submit}
disabled={login.isPending}
>
{login.isPending ? 'Logging in...' : 'Login'}
</button>
</form>
<p className={styles.footer}>
Don&apos;t have an account?{' '}
<Link to={ROUTES.REGISTER} className={styles.link}>
Sign up
</Link>
</p>
</div>
</div>
)
}
Component.displayName = 'Login'

View File

@ -0,0 +1,171 @@
// ===================
// © AngelaMos | 2025
// login.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: 100vh;
min-height: 100dvh;
@include flex-center;
background-color: $bg-default;
background-image: radial-gradient(
circle,
$bg-page 1px,
transparent 1px
);
background-size: 22px 22px;
padding: $space-4;
}
.card {
width: 100%;
max-width: 400px;
background-color: $black;
background-image: radial-gradient(
circle,
$bg-card 1px,
transparent 1px
);
background-size: 20px 20px;
border: 1px solid $border-default;
border-radius: $radius-lg;
padding: $space-8;
}
.header {
margin-bottom: $space-6;
}
.title {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $white;
margin-bottom: $space-2;
}
.subtitle {
font-size: $font-size-sm;
color: $text-light;
}
.form {
@include flex-column;
gap: $space-5;
}
.field {
@include flex-column;
gap: $space-2;
}
.label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $white;
}
.input {
width: 100%;
height: 48px;
padding: 0 $space-4;
background-color: transparent;
border: 1px solid $border-default;
border-radius: $radius-md;
font-size: $font-size-base;
color: $white;
@include transition-fast;
&::placeholder {
color: $text-muted;
}
&:focus {
outline: none;
border-color: $border-strong;
}
&[aria-invalid='true'] {
border-color: $error-default;
}
}
.inputWrapper {
position: relative;
width: 100%;
}
.eyeButton {
position: absolute;
right: $space-3;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: $text-muted;
cursor: pointer;
padding: $space-1;
@include transition-fast;
&:hover {
color: $white;
}
svg {
width: 20px;
height: 20px;
}
}
.error {
font-size: $font-size-xs;
color: $error-default;
}
.submit {
width: 100%;
height: 48px;
margin-top: $space-2;
display: flex;
align-items: center;
justify-content: center;
background-color: $white;
border: none;
border-radius: $radius-md;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $black;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(0.9);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.footer {
margin-top: $space-6;
text-align: center;
font-size: $font-size-sm;
color: $text-light;
}
.link {
color: $text-default;
text-decoration: underline;
text-underline-offset: 4px;
@include transition-fast;
@include hover {
color: $text-light;
}
}

View File

@ -0,0 +1,164 @@
/**
* ©AngelaMos | 2025
* index.tsx
*/
import { useState } from 'react'
import { LuEye, LuEyeOff } from 'react-icons/lu'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { z } from 'zod'
import { useRegister } from '@/api/hooks'
import { userCreateRequestSchema } from '@/api/types'
import { PASSWORD_CONSTRAINTS, ROUTES } from '@/config'
import { useAuthFormStore } from '@/core/lib'
import styles from './register.module.scss'
const registerFormSchema = userCreateRequestSchema
.extend({
confirmPassword: z
.string()
.min(
PASSWORD_CONSTRAINTS.MIN_LENGTH,
`Password must be at least ${PASSWORD_CONSTRAINTS.MIN_LENGTH} characters`
)
.max(PASSWORD_CONSTRAINTS.MAX_LENGTH),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
export function Component(): React.ReactElement {
const navigate = useNavigate()
const register = useRegister()
const { registerEmail, setRegisterEmail, clearRegisterForm } =
useAuthFormStore()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const handleSubmit = (e: React.FormEvent): void => {
e.preventDefault()
const result = registerFormSchema.safeParse({
email: registerEmail,
password,
confirmPassword,
})
if (!result.success) {
const firstError = result.error.issues[0]
toast.error(firstError.message)
return
}
register.mutate(
{ email: result.data.email, password: result.data.password },
{
onSuccess: () => {
clearRegisterForm()
toast.success('Account created successfully')
navigate(ROUTES.LOGIN)
},
}
)
}
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.header}>
<h1 className={styles.title}>Sign up</h1>
<p className={styles.subtitle}>Create a new account</p>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label className={styles.label} htmlFor="email">
Email
</label>
<input
id="email"
type="email"
className={styles.input}
placeholder="xxx@example.com"
value={registerEmail}
onChange={(e) => setRegisterEmail(e.target.value)}
autoComplete="email"
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="password">
Password
</label>
<div className={styles.inputWrapper}>
<input
id="password"
type={showPassword ? 'text' : 'password'}
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
<button
type="button"
className={styles.eyeButton}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <LuEyeOff /> : <LuEye />}
</button>
</div>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="confirmPassword">
Repeat Password
</label>
<div className={styles.inputWrapper}>
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
className={styles.input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
<button
type="button"
className={styles.eyeButton}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
aria-label={
showConfirmPassword ? 'Hide password' : 'Show password'
}
>
{showConfirmPassword ? <LuEyeOff /> : <LuEye />}
</button>
</div>
</div>
<button
type="submit"
className={styles.submit}
disabled={register.isPending}
>
{register.isPending ? 'Creating account...' : 'Sign up'}
</button>
</form>
<p className={styles.footer}>
Already have an account?{' '}
<Link to={ROUTES.LOGIN} className={styles.link}>
Login
</Link>
</p>
</div>
</div>
)
}
Component.displayName = 'Register'

View File

@ -0,0 +1,171 @@
// ===================
// © AngelaMos | 2025
// register.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: 100vh;
min-height: 100dvh;
@include flex-center;
background-color: $bg-default;
background-image: radial-gradient(
circle,
$bg-page 1px,
transparent 1px
);
background-size: 22px 22px;
padding: $space-4;
}
.card {
width: 100%;
max-width: 400px;
background-color: $black;
background-image: radial-gradient(
circle,
$bg-card 1px,
transparent 1px
);
background-size: 20px 20px;
border: 1px solid $border-default;
border-radius: $radius-lg;
padding: $space-8;
}
.header {
margin-bottom: $space-6;
}
.title {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $white;
margin-bottom: $space-2;
}
.subtitle {
font-size: $font-size-sm;
color: $text-light;
}
.form {
@include flex-column;
gap: $space-5;
}
.field {
@include flex-column;
gap: $space-2;
}
.label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $white;
}
.input {
width: 100%;
height: 48px;
padding: 0 $space-4;
background-color: transparent;
border: 1px solid $border-default;
border-radius: $radius-md;
font-size: $font-size-base;
color: $white;
@include transition-fast;
&::placeholder {
color: $text-muted;
}
&:focus {
outline: none;
border-color: $border-strong;
}
&[aria-invalid='true'] {
border-color: $error-default;
}
}
.inputWrapper {
position: relative;
width: 100%;
}
.eyeButton {
position: absolute;
right: $space-3;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: $text-muted;
cursor: pointer;
padding: $space-1;
@include transition-fast;
&:hover {
color: $white;
}
svg {
width: 20px;
height: 20px;
}
}
.error {
font-size: $font-size-xs;
color: $error-default;
}
.submit {
width: 100%;
height: 48px;
margin-top: $space-2;
display: flex;
align-items: center;
justify-content: center;
background-color: $white;
border: none;
border-radius: $radius-md;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $black;
cursor: pointer;
@include transition-fast;
@include hover {
filter: brightness(0.9);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.footer {
margin-top: $space-6;
text-align: center;
font-size: $font-size-sm;
color: $text-light;
}
.link {
color: $text-default;
text-decoration: underline;
text-underline-offset: 4px;
@include transition-fast;
@include hover {
color: $text-light;
}
}

View File

@ -0,0 +1,95 @@
/**
* ©AngelaMos | 2025
* index.tsx
*/
import styles from './settings.module.scss'
const AVAILABLE_HOOKS = [
{
name: 'useUpdateProfile()',
file: 'api/hooks/useUsers.ts',
description: 'Update user profile (full_name)',
endpoint: 'PATCH /api/v1/users/me',
},
{
name: 'useChangePassword()',
file: 'api/hooks/useAuth.ts',
description: 'Change password (current + new)',
endpoint: 'POST /api/v1/auth/change-password',
},
]
const AVAILABLE_STORES = [
{
name: 'useAuthStore()',
file: 'core/lib/auth.store.ts',
description: 'Access user state, logout, updateUser',
},
{
name: 'useUser()',
file: 'core/lib/auth.store.ts',
description: 'Get current user from store',
},
]
export function Component(): React.ReactElement {
return (
<div className={styles.page}>
<div className={styles.container}>
<div className={styles.header}>
<h1 className={styles.title}>Settings</h1>
<p className={styles.subtitle}>
Template page available hooks and stores for building your settings
UI
</p>
</div>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Available Hooks</h2>
<div className={styles.grid}>
{AVAILABLE_HOOKS.map((hook) => (
<div key={hook.name} className={styles.card}>
<code className={styles.hookName}>{hook.name}</code>
<p className={styles.description}>{hook.description}</p>
<div className={styles.meta}>
<span className={styles.file}>{hook.file}</span>
<span className={styles.endpoint}>{hook.endpoint}</span>
</div>
</div>
))}
</div>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Available Stores</h2>
<div className={styles.grid}>
{AVAILABLE_STORES.map((store) => (
<div key={store.name} className={styles.card}>
<code className={styles.hookName}>{store.name}</code>
<p className={styles.description}>{store.description}</p>
<div className={styles.meta}>
<span className={styles.file}>{store.file}</span>
</div>
</div>
))}
</div>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Suggested Features</h2>
<ul className={styles.list}>
<li>Profile form (full name, avatar)</li>
<li>Change password form</li>
<li>Email preferences</li>
<li>Theme toggle (dark/light)</li>
<li>Notification settings</li>
<li>Delete account</li>
</ul>
</section>
</div>
</div>
)
}
Component.displayName = 'Settings'

View File

@ -0,0 +1,109 @@
// ===================
// © AngelaMos | 2025
// settings.module.scss
// ===================
@use '@/styles' as *;
.page {
min-height: calc(100vh - 56px);
padding: $space-6;
background-color: $bg-default;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: $space-8;
}
.title {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $text-default;
margin-bottom: $space-2;
}
.subtitle {
font-size: $font-size-sm;
color: $text-lighter;
}
.section {
margin-bottom: $space-8;
}
.sectionTitle {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-light;
text-transform: uppercase;
letter-spacing: $tracking-wide;
margin-bottom: $space-4;
}
.grid {
display: grid;
gap: $space-4;
@include breakpoint-up('md') {
grid-template-columns: repeat(2, 1fr);
}
}
.card {
padding: $space-4;
background: $bg-surface-100;
border: 1px solid $border-default;
border-radius: $radius-lg;
}
.hookName {
display: inline-block;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-default;
background: $bg-surface-200;
padding: $space-1 $space-2;
border-radius: $radius-sm;
margin-bottom: $space-2;
}
.description {
font-size: $font-size-sm;
color: $text-light;
margin-bottom: $space-3;
line-height: $line-height-relaxed;
}
.meta {
@include flex-column;
gap: $space-1;
}
.file {
font-size: $font-size-xs;
color: $text-lighter;
font-family: $font-mono;
}
.endpoint {
font-size: $font-size-xs;
color: $text-muted;
font-family: $font-mono;
}
.list {
@include flex-column;
gap: $space-2;
padding-left: $space-5;
list-style: disc;
li {
font-size: $font-size-sm;
color: $text-light;
}
}

View File

@ -0,0 +1,29 @@
// ===================
// © AngelaMos | 2025
// styles.scss
// ===================
@forward 'styles/tokens';
@forward 'styles/fonts';
@forward 'styles/mixins';
@use 'styles/reset';
@use 'styles/tokens' as *;
@use 'styles/fonts' as *;
#root {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: $bg-default;
}
.app {
flex: 1;
display: flex;
flex-direction: column;
background: $bg-default;
color: $text-default;
font-family: $font-sans;
}

View File

@ -0,0 +1,12 @@
// ===================
// © AngelaMos | 2025
// _fonts.scss
// ===================
@use 'tokens' as *;
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto,
'Helvetica Neue', Arial, sans-serif;
$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
'Liberation Mono', monospace;

View File

@ -0,0 +1,8 @@
// ===================
// © AngelaMos | 2025
// _index.scss
// ===================
@forward 'tokens';
@forward 'fonts';
@forward 'mixins';

View File

@ -0,0 +1,120 @@
// ===================
// © AngelaMos | 2025
// _mixins.scss
// ===================
@use 'sass:map';
@use 'sass:list';
@use 'tokens' as *;
$breakpoints: (
'xs': $breakpoint-xs,
'sm': $breakpoint-sm,
'md': $breakpoint-md,
'lg': $breakpoint-lg,
'xl': $breakpoint-xl,
'2xl': $breakpoint-2xl,
);
@mixin breakpoint-up($size) {
@media (min-width: map.get($breakpoints, $size)) {
@content;
}
}
@mixin breakpoint-down($size) {
@media (width < map.get($breakpoints, $size)) {
@content;
}
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
@mixin flex-column-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@mixin truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin line-clamp($lines: 2) {
display: -webkit-box;
-webkit-line-clamp: #{$lines};
-webkit-box-orient: vertical;
overflow: hidden;
}
@mixin transition-fast {
transition-property: background-color, border-color, color, opacity;
transition-duration: $duration-fast;
transition-timing-function: $ease-out;
}
@mixin transition-normal {
transition-property: background-color, border-color, color, opacity;
transition-duration: $duration-normal;
transition-timing-function: $ease-out;
}
@mixin absolute-fill {
position: absolute;
inset: 0;
}
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@mixin hover {
@media (hover: hover) and (pointer: fine) {
&:hover {
@content;
}
}
}
@mixin hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}

View File

@ -0,0 +1,198 @@
// ===================
// © AngelaMos | 2025
// _reset.scss
// ===================
@use 'tokens' as *;
@use 'fonts' as *;
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
html {
font-size: 16px;
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
overflow-x: hidden;
}
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
scroll-behavior: smooth;
}
}
body {
min-height: 100vh;
min-height: 100dvh;
line-height: $line-height-normal;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
background-color: $bg-default;
color: $text-default;
font-family: $font-sans;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: $line-height-tight;
text-wrap: balance;
overflow-wrap: break-word;
font-weight: $font-weight-semibold;
}
p {
text-wrap: pretty;
overflow-wrap: break-word;
}
ul,
ol {
list-style: none;
}
a {
color: inherit;
text-decoration: none;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
input[type='text'],
input[type='email'],
input[type='password'],
input[type='search'],
input[type='number'],
input[type='tel'],
input[type='url'],
textarea,
select {
font-size: $font-size-sm;
appearance: none;
}
button {
background: none;
border: none;
cursor: pointer;
text-align: inherit;
font-family: inherit;
}
fieldset {
border: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
textarea:not([rows]) {
min-height: 10em;
}
:target {
scroll-margin-block: 5ex;
}
:focus-visible {
outline: 2px solid $border-strong;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
[hidden] {
display: none !important;
}
[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
dialog {
padding: 0;
border: none;
background: transparent;
}
summary {
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@supports (padding: max(0px)) {
body {
padding-left: max(0px, env(safe-area-inset-left));
padding-right: max(0px, env(safe-area-inset-right));
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border-default;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: $border-strong;
}
::selection {
background-color: $bg-selection;
}

View File

@ -0,0 +1,168 @@
// ===================
// © AngelaMos | 2025
// _tokens.scss
// ===================
// ============================================================================
// SPACING (8px base system)
// ============================================================================
$space-0: 0;
$space-px: 1px;
$space-0-5: 0.125rem;
$space-1: 0.25rem;
$space-1-5: 0.375rem;
$space-2: 0.5rem;
$space-2-5: 0.625rem;
$space-3: 0.75rem;
$space-3-5: 0.875rem;
$space-4: 1rem;
$space-5: 1.25rem;
$space-6: 1.5rem;
$space-7: 1.75rem;
$space-8: 2rem;
$space-9: 2.25rem;
$space-10: 2.5rem;
$space-11: 2.75rem;
$space-12: 3rem;
$space-14: 3.5rem;
$space-16: 4rem;
$space-20: 5rem;
$space-24: 6rem;
$space-28: 7rem;
$space-32: 8rem;
// ============================================================================
// TYPOGRAPHY SCALE
// ============================================================================
$font-size-3xs: 0.625rem;
$font-size-2xs: 0.6875rem;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 1.875rem;
$font-size-4xl: 2.25rem;
$font-size-5xl: 3rem;
// ============================================================================
// FONT WEIGHTS
// ============================================================================
$font-weight-regular: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
// ============================================================================
// LINE HEIGHTS
// ============================================================================
$line-height-none: 1;
$line-height-tight: 1.2;
$line-height-snug: 1.375;
$line-height-normal: 1.5;
$line-height-relaxed: 1.625;
// ============================================================================
// LETTER SPACING
// ============================================================================
$tracking-tighter: -0.05em;
$tracking-tight: -0.025em;
$tracking-normal: 0;
$tracking-wide: 0.025em;
$tracking-wider: 0.05em;
// ============================================================================
// COLORS
// ============================================================================
$white: hsl(0, 0%, 100%);
$black: hsl(0, 0%, 0%);
// Auth
$bg-page: hsl(0, 0%, 10.5%);
$bg-card: hsl(0, 0%, 6.2%);
// Home/landing
$bg-landing: hsl(0, 0%, 10.8%);
$bg-default: hsl(0, 0%, 7.1%);
$bg-alternative: hsl(0, 0%, 5.9%);
$bg-surface-75: hsl(0, 0%, 9%);
$bg-surface-100: hsl(0, 0%, 12.2%);
$bg-surface-200: hsl(0, 0%, 14.1%);
$bg-surface-300: hsl(0, 0%, 16.1%);
$bg-control: hsl(0, 0%, 10%);
$bg-selection: hsl(0, 0%, 19.2%);
$bg-overlay: hsl(0, 0%, 14.1%);
$bg-overlay-hover: hsl(0, 0%, 18%);
$border-muted: hsl(0, 0%, 11.1%);
$border-default: hsl(0, 0%, 18%);
$border-strong: hsl(0, 0%, 22.4%);
$border-stronger: hsl(0, 0%, 27.1%);
$border-control: hsl(0, 0%, 22.4%);
$text-default: hsl(0, 0%, 98%);
$text-light: hsl(0, 0%, 70.6%);
$text-lighter: hsl(0, 0%, 53.7%);
$text-muted: hsl(0, 0%, 30.2%);
$error-default: hsl(0, 72%, 51%);
$error-light: hsl(0, 72%, 65%);
// ============================================================================
// BORDER RADIUS
// ============================================================================
$radius-none: 0;
$radius-xs: 2px;
$radius-sm: 4px;
$radius-md: 6px;
$radius-lg: 8px;
$radius-xl: 12px;
$radius-full: 9999px;
// ============================================================================
// Z-INDEX SCALE
// ============================================================================
$z-hide: -1;
$z-base: 0;
$z-dropdown: 100;
$z-sticky: 200;
$z-fixed: 300;
$z-overlay: 400;
$z-modal: 500;
$z-popover: 600;
$z-tooltip: 700;
$z-toast: 800;
$z-max: 9999;
// ============================================================================
// TRANSITIONS
// ============================================================================
$duration-instant: 0ms;
$duration-fast: 100ms;
$duration-normal: 150ms;
$duration-slow: 200ms;
$ease-out: cubic-bezier(0, 0, 0.2, 1);
$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
// ============================================================================
// BREAKPOINTS
// ============================================================================
$breakpoint-xs: 360px;
$breakpoint-sm: 480px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
// ============================================================================
// CONTAINER WIDTHS
// ============================================================================
$container-xs: 20rem;
$container-sm: 24rem;
$container-md: 28rem;
$container-lg: 32rem;
$container-xl: 36rem;
$container-2xl: 42rem;
$container-full: 100%;

View File

@ -0,0 +1,107 @@
// ©AngelaMos | 2025
// stylelint.config.js
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'],
rules: {
'block-no-empty': true,
'declaration-no-important': true,
'color-no-invalid-hex': true,
'property-no-unknown': true,
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
'selector-class-pattern': [
'^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$',
{
message:
'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)',
},
],
'value-keyword-case': [
'lower',
{
camelCaseSvgKeywords: true,
ignoreKeywords: [
'BlinkMacSystemFont',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'Roboto',
'Arial',
'Helvetica',
'Times',
'Georgia',
'Verdana',
'Tahoma',
'Trebuchet',
'Impact',
'Comic',
],
},
],
'property-no-vendor-prefix': [
true,
{
ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'],
},
],
'value-no-vendor-prefix': true,
'selector-no-vendor-prefix': true,
'property-no-deprecated': [
true,
{
ignoreProperties: ['clip'],
},
],
'container-name-pattern': null,
'layer-name-pattern': null,
'scss/at-rule-no-unknown': true,
'scss/declaration-nested-properties-no-divided-groups': true,
'scss/dollar-variable-no-missing-interpolation': true,
'scss/dollar-variable-empty-line-before': null,
'declaration-empty-line-before': null,
'custom-property-empty-line-before': null,
'no-descending-specificity': null,
'media-feature-name-no-unknown': [
true,
{
ignoreMediaFeatureNames: ['map'],
},
],
'color-function-notation': null,
'hue-degree-notation': null,
},
ignoreFiles: [
'node_modules/**',
'dist/**',
'build/**',
'**/*.js',
'**/*.ts',
'**/*.tsx',
],
overrides: [
{
files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'],
rules: {
'declaration-no-important': null,
'scss/comment-no-empty': null,
},
},
],
}

View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

Some files were not shown because too many files have changed in this diff Show More