diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore new file mode 100644 index 00000000..48cf6544 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore @@ -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/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml new file mode 100644 index 00000000..5f03fe95 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example new file mode 100644 index 00000000..f542823a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore new file mode 100644 index 00000000..3688c87b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore @@ -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* diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml new file mode 100644 index 00000000..679c3e2c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go new file mode 100644 index 00000000..150d2b52 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml new file mode 100644 index 00000000..01aa28f0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod new file mode 100644 index 00000000..e5981e90 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -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 +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum new file mode 100644 index 00000000..0a9a9619 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -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= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go new file mode 100644 index 00000000..4f75ab22 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go @@ -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"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go new file mode 100644 index 00000000..9fc1cfa1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go @@ -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"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go new file mode 100644 index 00000000..8ce8a4e5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go new file mode 100644 index 00000000..16b3b6bd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go new file mode 100644 index 00000000..026cf056 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go new file mode 100644 index 00000000..2abb0360 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go new file mode 100644 index 00000000..3629051c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go new file mode 100644 index 00000000..ff266e50 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go new file mode 100644 index 00000000..766af7f8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go new file mode 100644 index 00000000..b478a6ee --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go new file mode 100644 index 00000000..822b6cc9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go @@ -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() +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go new file mode 100644 index 00000000..e37ac737 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go @@ -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, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go new file mode 100644 index 00000000..05065e1a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go new file mode 100644 index 00000000..950bba50 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go new file mode 100644 index 00000000..f77cbd96 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go @@ -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" + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go new file mode 100644 index 00000000..e7003c6a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go @@ -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"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go new file mode 100644 index 00000000..0f9fb5f2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go @@ -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" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go new file mode 100644 index 00000000..332d48f3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go @@ -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) + }) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go new file mode 100644 index 00000000..602ed4c7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go new file mode 100644 index 00000000..c85519a1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go @@ -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, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go new file mode 100644 index 00000000..77d98165 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go @@ -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 "" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go new file mode 100644 index 00000000..58d5cbac --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go new file mode 100644 index 00000000..ba2b0686 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go new file mode 100644 index 00000000..f830b054 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go @@ -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" +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go new file mode 100644 index 00000000..8a21889b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go new file mode 100644 index 00000000..a255b08e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go @@ -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 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go new file mode 100644 index 00000000..699d24d5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go @@ -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) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker new file mode 100644 index 00000000..557fd500 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker @@ -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"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker new file mode 100644 index 00000000..e3400dcb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker @@ -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"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker new file mode 100644 index 00000000..d903317f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker @@ -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"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker new file mode 100644 index 00000000..9d5bf2b5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker @@ -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;"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore @@ -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? diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore new file mode 100644 index 00000000..37da03e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json new file mode 100644 index 00000000..7f29029a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json @@ -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" + } + } + } + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html new file mode 100644 index 00000000..ce1f11bb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + Monitor The Situation Dashboard + + + + +
+ + + diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json new file mode 100644 index 00000000..fc6e78c7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json @@ -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" + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..d04a7163 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml @@ -0,0 +1,2603 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.1) + axios: + specifier: ^1.13.0 + version: 1.13.2 + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.0 + version: 19.2.1(react@19.2.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.2.1) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + zod: + specifier: ^4.1.13 + version: 4.1.13 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + sass: + specifier: ^1.95.0 + version: 1.95.0 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.6': + resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} + + '@cacheable/utils@2.3.2': + resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@24.10.2': + resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.0: + resolution: {integrity: sha512-HHiAvOBmlcR2f3SQ7kdlYD8+AUJG+wlFZ/Ze8tl1Vzvz0MdOh8IYA/EFU4ve8t1/sZ0j4MGi7ST5MoTwHessQA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.3.0: + resolution: {integrity: sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.14.0: + resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.10.1: + resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.95.0: + resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.13.0: + resolution: {integrity: sha512-kZPwFUJkfup2gP1enlrS2h9U5+T5wFoqzJ1n/56AlpwSj28kmFe7ww/QFydvPsg5gLjWchAwWWBLtterynZrOw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@cacheable/memory@2.0.6': + dependencies: + '@cacheable/utils': 2.3.2 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.14.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.2': + dependencies: + hashery: 1.3.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.3.0 + hookified: 1.14.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.0': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@19.2.1) + react: 19.2.1 + + '@tanstack/react-query@5.90.12(react@19.2.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 19.2.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/node@24.10.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + + cacheable@2.3.0: + dependencies: + '@cacheable/memory': 2.0.6 + '@cacheable/utils': 2.3.2 + hookified: 1.14.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001759: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.0 + flatted: 3.3.3 + hookified: 1.14.0 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.3.0: + dependencies: + hookified: 1.14.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.14.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.14.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-error-boundary@6.0.0(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.1 + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + + react-refresh@0.18.0: {} + + react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + cookie: 1.1.1 + react: 19.2.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.1(react@19.2.1) + + react@19.2.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + sass: 1.95.0 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.95.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.13.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.13.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + - typescript + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.1.13: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.1 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 00000000..01d0a08e Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 00000000..3ea8cdf1 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..ec126db9 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..75b62905 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..338e90d0 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..cf617467 Binary files /dev/null and b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..1dd91123 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest @@ -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"} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx new file mode 100644 index 00000000..77f8c360 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx @@ -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 ( + +
+ + +
+ +
+ ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..1bc139b2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './useAdmin' +export * from './useAuth' +export * from './useUsers' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts new file mode 100644 index 00000000..b339b0de --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts @@ -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 => { + const response = await apiClient.get(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 => { + 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 => { + const response = await apiClient.get( + 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 => { + return useQuery({ + queryKey: adminQueries.users.byId(id), + queryFn: () => fetchAdminUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performAdminCreateUser = async ( + data: AdminUserCreateRequest +): Promise => { + const response = await apiClient.post( + 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 => { + 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 => { + const response = await apiClient.patch( + 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 => { + 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 => { + await apiClient.delete(API_ENDPOINTS.ADMIN.USERS.DELETE(id)) +} + +export const useAdminDeleteUser = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminDeleteUser, + onSuccess: async (_, id: string): Promise => { + 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) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts new file mode 100644 index 00000000..a0aea655 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts @@ -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 => { + const response = await apiClient.get(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 => { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + + return useQuery({ + queryKey: authQueries.me(), + queryFn: fetchCurrentUser, + enabled: isAuthenticated, + ...QUERY_STRATEGIES.auth, + }) +} + +const performLogin = async ( + credentials: LoginRequest +): Promise => { + const formData = new URLSearchParams() + formData.append('username', credentials.username) + formData.append('password', credentials.password) + + const response = await apiClient.post( + 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 => { + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT) +} + +export const useLogout = (): UseMutationResult => { + 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 => { + const response = await apiClient.post(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 => { + 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) => { + const queryClient = useQueryClient() + const { setAccessToken, login, logout } = useAuthStore() + + return async (): Promise => { + try { + const response = await apiClient.post(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(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() }) + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts new file mode 100644 index 00000000..ba3a2264 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts @@ -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 => { + const response = await apiClient.get(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 => { + return useQuery({ + queryKey: userQueries.byId(id), + queryFn: () => fetchUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performRegister = async ( + data: UserCreateRequest +): Promise => { + const response = await apiClient.post( + 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 => { + const response = await apiClient.patch(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) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts new file mode 100644 index 00000000..0840a7c1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './hooks' +export * from './types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts new file mode 100644 index 00000000..bf1ff764 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts @@ -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 +export type TokenResponse = z.infer +export type TokenWithUserResponse = z.infer +export type LoginRequest = z.infer +export type RegisterRequest = z.infer +export type PasswordChangeRequest = z.infer +export type LogoutAllResponse = z.infer + +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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts new file mode 100644 index 00000000..7f26f3c6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './auth.types' +export * from './user.types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts new file mode 100644 index 00000000..2fd36234 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts @@ -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 +export type UserCreateRequest = z.infer +export type UserUpdateRequest = z.infer +export type AdminUserCreateRequest = z.infer +export type AdminUserUpdateRequest = z.infer +export type PaginationParams = z.infer + +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] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx new file mode 100644 index 00000000..6b0a7329 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx @@ -0,0 +1,4 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts new file mode 100644 index 00000000..8c0d2244 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts @@ -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 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..8347fa00 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts @@ -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 => { + 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 => { + return Promise.reject(error) + } +) + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError): Promise => { + 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((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)) + } +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..fde5ec65 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts @@ -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 + + constructor( + message: string, + code: ApiErrorCode, + statusCode: number, + details?: Record + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + getUserMessage(): string { + const messages: Record = { + [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): 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 | 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 = { + 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 + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts new file mode 100644 index 00000000..1818cd7e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './api.config' +export * from './errors' +export * from './query.config' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..8244b22d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts @@ -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, + }), +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx new file mode 100644 index 00000000..486eec78 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx @@ -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
Loading...
+ } + + if (!isAuthenticated) { + return ( + + ) + } + + if ( + allowedRoles !== undefined && + allowedRoles.length > 0 && + user !== null && + !allowedRoles.includes(user.role) + ) { + return + } + + return +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx new file mode 100644 index 00000000..cb2473c9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx @@ -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: , + children: [ + { + element: , + children: [ + { + path: ROUTES.DASHBOARD, + lazy: () => import('@/pages/dashboard'), + }, + { + path: ROUTES.SETTINGS, + lazy: () => import('@/pages/settings'), + }, + ], + }, + ], + }, + { + element: , + children: [ + { + element: , + 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) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss new file mode 100644 index 00000000..b57d7a96 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss @@ -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%; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx new file mode 100644 index 00000000..65fe7f37 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx @@ -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 ( +
+

Something went wrong

+
{error.message}
+
+ ) +} + +function ShellLoading(): React.ReactElement { + return
Loading...
+} + +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 ( +
+ + + {sidebarOpen && ( + +

{pageTitle}

+
+ +
+ + {avatarLetter} + +
+ + +
+ + }> + + + +
+ + + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss new file mode 100644 index 00000000..d50ab7fb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts new file mode 100644 index 00000000..22c422c5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts @@ -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()( + 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' } + ) +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts new file mode 100644 index 00000000..a02cd0c9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts @@ -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) => void +} + +type AuthStore = AuthState & AuthActions + +export const useAuthStore = create()( + 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 } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..191757f4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './auth.form.store' +export * from './auth.store' +export * from './shell.ui.store' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts new file mode 100644 index 00000000..d601a53b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts @@ -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()( + 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) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx new file mode 100644 index 00000000..1b1f2c8a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx @@ -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( + + + +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss new file mode 100644 index 00000000..ef515000 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx new file mode 100644 index 00000000..53fa22a1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx @@ -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(PAGINATION.DEFAULT_PAGE) + const [modal, setModal] = useState({ 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 ( +
+
+

Users

+ +
+ +
+
+
Email
+
Name
+
Role
+
Status
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No users found
+ )} + + {data?.items.map((user) => ( +
+
+ {user.email} +
+
+ {user.full_name ?? '—'} +
+
+ + {user.role} + +
+
+ + {user.is_active ? 'Active' : 'Inactive'} + +
+
+ + +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} users) + +
+ + +
+
+ )} +
+ + {modal.type === 'create' && ( +
+ +
+
{ + e.preventDefault() + handleCreate(new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ + )} + + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.user.id, new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + )} + + {modal.type === 'delete' && ( +
+ +
+
+

+ Are you sure you want to delete{' '} + {modal.user.email}? + This action cannot be undone. +

+
+ + +
+
+ + + )} + + ) +} + +Component.displayName = 'AdminUsers' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss new file mode 100644 index 00000000..ecac295d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..5d3f97ec --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx @@ -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 ( +
+
+
+

+ Welcome{user?.full_name ? `, ${user.full_name}` : ''} +

+

+ Template page — build your dashboard here +

+
+ +
+
+ {user?.full_name?.[0]?.toUpperCase() ?? + user?.email?.[0]?.toUpperCase() ?? + 'U'} +
+
+ {user?.full_name ?? 'User'} + {user?.email} + {user?.role} +
+
+ +
+

Available Stores

+
+ {AVAILABLE_STORES.map((store) => ( +
+ {store.name} +

{store.description}

+ {store.file} +
+ ))} +
+
+ +
+

Suggested Features

+
    + {SUGGESTED_FEATURES.map((feature) => ( +
  • {feature}
  • + ))} +
+
+
+
+ ) +} + +Component.displayName = 'Dashboard' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx new file mode 100644 index 00000000..48729f0b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx @@ -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 ( +
+
+

Full Stack Template

+

by Carter Perez

+ + + +
+ +
+

+ Boilerplate for medium-large scale applications. Built with modern + patterns, strict typing, and security best practices. +

+ +
+
+

Frontend

+
    +
  • React 19 + TypeScript with strict mode
  • +
  • TanStack Query for server state caching
  • +
  • Zustand stores with persistence
  • +
  • Axios interceptors with auto token refresh
  • +
  • Zod runtime validation on API responses
  • +
  • SCSS modules with design tokens
  • +
+
+ +
+

Backend

+
    +
  • DDD + DI Architecture
  • +
  • FastAPI with async/await throughout
  • +
  • SQLAlchemy 2.0+ async with connection pooling
  • +
  • JWT auth with token rotation and replay detection
  • +
  • Argon2id hashing with timing safe verification
  • +
  • Pydantic v2 strict validation
  • +
+
+ +
+

Infrastructure

+
    +
  • Docker Compose with dev/prod configs
  • +
  • Nginx reverse proxy with rate limiting
  • +
  • PostgreSQL 18 + Redis 7
  • +
  • Health checks and graceful shutdown
  • +
+
+ +
+

DevOps

+
    +
  • GitHub Actions CI (Ruff, Pylint, Mypy, Biome)
  • +
  • Strict type checking on both ends
  • +
  • Alembic async migrations
  • +
+
+
+ +
+ + Try Auth Flow + + + API Docs + +
+
+
+ ) +} + +Component.displayName = 'Landing' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss new file mode 100644 index 00000000..3013e87a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss @@ -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; + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx new file mode 100644 index 00000000..13caa91c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx @@ -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 ( +
+
+
+

Login

+

Welcome back

+
+ +
+
+ + setLoginEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="current-password" + /> + +
+
+ + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ) +} + +Component.displayName = 'Login' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss new file mode 100644 index 00000000..6d01ed24 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx new file mode 100644 index 00000000..f0f003f7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx @@ -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 ( +
+
+
+

Sign up

+

Create a new account

+
+ +
+
+ + setRegisterEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ + +
+ +

+ Already have an account?{' '} + + Login + +

+
+
+ ) +} + +Component.displayName = 'Register' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss new file mode 100644 index 00000000..55ad5717 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx new file mode 100644 index 00000000..8a22eb65 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx @@ -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 ( +
+
+
+

Settings

+

+ Template page — available hooks and stores for building your settings + UI +

+
+ +
+

Available Hooks

+
+ {AVAILABLE_HOOKS.map((hook) => ( +
+ {hook.name} +

{hook.description}

+
+ {hook.file} + {hook.endpoint} +
+
+ ))} +
+
+ +
+

Available Stores

+
+ {AVAILABLE_STORES.map((store) => ( +
+ {store.name} +

{store.description}

+
+ {store.file} +
+
+ ))} +
+
+ +
+

Suggested Features

+
    +
  • Profile form (full name, avatar)
  • +
  • Change password form
  • +
  • Email preferences
  • +
  • Theme toggle (dark/light)
  • +
  • Notification settings
  • +
  • Delete account
  • +
+
+
+
+ ) +} + +Component.displayName = 'Settings' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss new file mode 100644 index 00000000..16d85855 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss new file mode 100644 index 00000000..4ac95fe0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss @@ -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; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss new file mode 100644 index 00000000..4f517884 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss @@ -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; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss new file mode 100644 index 00000000..ac5a8266 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// _index.scss +// =================== + +@forward 'tokens'; +@forward 'fonts'; +@forward 'mixins'; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss new file mode 100644 index 00000000..6b5b1ba9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss @@ -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; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss new file mode 100644 index 00000000..b6af56b5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss @@ -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; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss new file mode 100644 index 00000000..be1f6dd6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss @@ -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%; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js new file mode 100644 index 00000000..56ebd7bc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js @@ -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, + }, + }, + ], +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json new file mode 100644 index 00000000..afe17f60 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json @@ -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"] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json new file mode 100644 index 00000000..a96b3e59 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts new file mode 100644 index 00000000..a8d5d478 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts @@ -0,0 +1,71 @@ +/** + * ©AngelaMos | 2025 + * vite.config.ts + */ + +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, path.resolve(__dirname, '..'), '') + const isDev = mode === 'development' + + return { + plugins: [react(), tsconfigPaths()], + + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + + css: { + preprocessorOptions: { + scss: {}, + }, + }, + + server: { + port: 5173, + host: '0.0.0.0', + proxy: { + '/api': { + target: env.VITE_API_TARGET || 'http://localhost:8000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ''), + }, + }, + }, + + build: { + target: 'esnext', + cssTarget: 'chrome100', + sourcemap: isDev ? true : 'hidden', + minify: 'oxc', + rollupOptions: { + output: { + manualChunks(id: string): string | undefined { + if (id.includes('node_modules')) { + if (id.includes('react-dom') || id.includes('react-router')) { + return 'vendor-react' + } + if (id.includes('@tanstack/react-query')) { + return 'vendor-query' + } + if (id.includes('zustand')) { + return 'vendor-state' + } + } + return undefined + }, + }, + }, + }, + + preview: { + port: 4173, + }, + } +})