feat(monitor): infra scaffold — Dockerfiles, conf/ tree, .gitignore
This commit is contained in:
parent
24c484386b
commit
470533fd66
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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*
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
¶ms.memory,
|
||||
¶ms.time,
|
||||
¶ms.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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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"}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './useAdmin'
|
||||
export * from './useAuth'
|
||||
export * from './useUsers'
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './auth.types'
|
||||
export * from './user.types'
|
||||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* ©AngelaMos | 2025
|
||||
* index.tsx
|
||||
*/
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './api.config'
|
||||
export * from './errors'
|
||||
export * from './query.config'
|
||||
|
|
@ -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,
|
||||
}),
|
||||
})
|
||||
|
|
@ -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 />
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }
|
||||
)
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// index.ts
|
||||
// ===================
|
||||
|
||||
export * from './auth.form.store'
|
||||
export * from './auth.store'
|
||||
export * from './shell.ui.store'
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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't have an account?{' '}
|
||||
<Link to={ROUTES.REGISTER} className={styles.link}>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Login'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2025
|
||||
// _index.scss
|
||||
// ===================
|
||||
|
||||
@forward 'tokens';
|
||||
@forward 'fonts';
|
||||
@forward 'mixins';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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%;
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue