From 470533fd662269ed94df5d6bd9d0aa24c8af2867 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:12:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20infra=20scaffold=20=E2=80=94?= =?UTF-8?q?=20Dockerfiles,=20conf/=20tree,=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.gitignore | 18 + .../backend/.air.toml | 29 + .../backend/.env.example | 33 + .../backend/.gitignore | 44 + .../backend/.golangci.yml | 113 + .../backend/cmd/api/main.go | 221 ++ .../backend/config.yaml | 53 + .../backend/go.mod | 69 + .../backend/go.sum | 165 ++ .../backend/internal/admin/handler.go | 208 ++ .../backend/internal/auth/dto.go | 62 + .../backend/internal/auth/entity.go | 47 + .../backend/internal/auth/handler.go | 316 ++ .../backend/internal/auth/jwt.go | 295 ++ .../backend/internal/auth/repository.go | 236 ++ .../backend/internal/auth/service.go | 411 +++ .../backend/internal/config/config.go | 302 ++ .../backend/internal/core/database.go | 145 + .../backend/internal/core/errors.go | 169 ++ .../backend/internal/core/redis.go | 63 + .../backend/internal/core/response.go | 120 + .../backend/internal/core/security.go | 218 ++ .../backend/internal/core/telemetry.go | 142 + .../backend/internal/core/validation.go | 42 + .../backend/internal/health/handler.go | 195 ++ .../backend/internal/middleware/auth.go | 189 ++ .../backend/internal/middleware/headers.go | 103 + .../backend/internal/middleware/logging.go | 99 + .../backend/internal/middleware/ratelimit.go | 375 +++ .../backend/internal/middleware/request_id.go | 40 + .../backend/internal/server/server.go | 108 + .../backend/internal/user/dto.go | 84 + .../backend/internal/user/entity.go | 40 + .../backend/internal/user/handler.go | 288 ++ .../backend/internal/user/repository.go | 289 ++ .../backend/internal/user/service.go | 256 ++ .../conf/docker/dev/go.docker | 19 + .../conf/docker/dev/vite.docker | 14 + .../conf/docker/prod/go.docker | 26 + .../conf/docker/prod/vite.docker | 26 + .../frontend/.dockerignore | 15 + .../frontend/.gitignore | 25 + .../frontend/.stylelintignore | 22 + .../frontend/biome.json | 94 + .../frontend/index.html | 44 + .../frontend/package.json | 50 + .../frontend/pnpm-lock.yaml | 2603 +++++++++++++++++ .../public/assets/android-chrome-192x192.png | Bin 0 -> 25807 bytes .../public/assets/android-chrome-512x512.png | Bin 0 -> 73395 bytes .../public/assets/apple-touch-icon.png | Bin 0 -> 22963 bytes .../frontend/public/assets/favicon-16x16.png | Bin 0 -> 746 bytes .../frontend/public/assets/favicon-32x32.png | Bin 0 -> 2032 bytes .../frontend/public/assets/favicon.ico | Bin 0 -> 15406 bytes .../frontend/public/assets/site.webmanifest | 1 + .../frontend/src/App.tsx | 36 + .../frontend/src/api/hooks/index.ts | 8 + .../frontend/src/api/hooks/useAdmin.ts | 219 ++ .../frontend/src/api/hooks/useAuth.ts | 243 ++ .../frontend/src/api/hooks/useUsers.ts | 138 + .../frontend/src/api/index.ts | 7 + .../frontend/src/api/types/auth.types.ts | 140 + .../frontend/src/api/types/index.ts | 7 + .../frontend/src/api/types/user.types.ts | 127 + .../frontend/src/components/index.tsx | 4 + .../frontend/src/config.ts | 116 + .../frontend/src/core/api/api.config.ts | 160 + .../frontend/src/core/api/errors.ts | 114 + .../frontend/src/core/api/index.ts | 8 + .../frontend/src/core/api/query.config.ts | 105 + .../frontend/src/core/app/protected-route.tsx | 47 + .../frontend/src/core/app/routers.tsx | 67 + .../frontend/src/core/app/shell.module.scss | 331 +++ .../frontend/src/core/app/shell.tsx | 160 + .../frontend/src/core/app/toast.module.scss | 67 + .../frontend/src/core/lib/auth.form.store.ts | 43 + .../frontend/src/core/lib/auth.store.ts | 105 + .../frontend/src/core/lib/index.ts | 8 + .../frontend/src/core/lib/shell.ui.store.ts | 63 + .../frontend/src/main.tsx | 15 + .../src/pages/admin/admin.module.scss | 468 +++ .../frontend/src/pages/admin/index.tsx | 415 +++ .../src/pages/dashboard/dashboard.module.scss | 152 + .../frontend/src/pages/dashboard/index.tsx | 90 + .../frontend/src/pages/landing/index.tsx | 97 + .../src/pages/landing/landing.module.scss | 161 + .../frontend/src/pages/login/index.tsx | 114 + .../src/pages/login/login.module.scss | 171 ++ .../frontend/src/pages/register/index.tsx | 164 ++ .../src/pages/register/register.module.scss | 171 ++ .../frontend/src/pages/settings/index.tsx | 95 + .../src/pages/settings/settings.module.scss | 109 + .../frontend/src/styles.scss | 29 + .../frontend/src/styles/_fonts.scss | 12 + .../frontend/src/styles/_index.scss | 8 + .../frontend/src/styles/_mixins.scss | 120 + .../frontend/src/styles/_reset.scss | 198 ++ .../frontend/src/styles/_tokens.scss | 168 ++ .../frontend/stylelint.config.js | 107 + .../frontend/tsconfig.app.json | 31 + .../frontend/tsconfig.json | 7 + .../frontend/tsconfig.node.json | 24 + .../frontend/vite.config.ts | 71 + 102 files changed, 13846 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts 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 0000000000000000000000000000000000000000..01d0a08ef524c65021a71f0c177b9d1317e7f986 GIT binary patch literal 25807 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%0Aq;P^m<|We$(VC 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 gF)%Qokv0KRi0n{r~^~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ec126db919dc679289e9ef69e842e82d8c5bbf5d GIT binary patch literal 22963 zcmXt9Q*Rqd)s z1vzm9I2^byU%ntnN{A?dUz`7Xz(9eYd8eS5zI;LcA}J!M;_>w&2ew;v0dv&$D`0PPa3J(vjqJ z2GNBU3lM7d~zX}rLC2w8RfQAHVxCaQyyqoOj+d z!#DAjcMPPG9xeARhQx9NGD zQ`-gpN32FeFtnPDB5zNZ(o`8OTXlUOXKWt3p_rW?k$Ho-f&01npC51bOEuc+=+tV- zlnE?e4;H+@4d+^1R;%Qzj$?wQaz$a&@#ML5M&so8cn1$s0;RV_WqDWS_Ap$ITW1YY zhmD^sA)MCBG#P9v&PS7y?#Q3d`$(==8>_URXH|8j+I&6V*4yj>jiK-?mkoNIj3aYE zX^b-ULlHkHF(Lr!!w2Y1w^pk_2$I!mQ9|DzhnPgNA+BgR=<~LN*o;qVzEx2-*%&2u|zt} zZ8)mO8))_Va4IzU-%{xW@jL7U@V%dqKAtX!r%*Mf9{h+Fs3o&# zzfVO{t1=&ur7HEIs`}aKZ_HZr=A@e0_A$+UeKLr|uX4LL{J3Ua2OE3NA*M>)Qdn5% zVwytbb?f-jP^Y`aDwpUWd;5}=WxLcEk%)alBiY8t#?r(>2^QT#SJxrVqj&PG+Z76h ztFT(C%w+KE-txJZ1s8`*p5JRlRP^859;e*TVN89C{vb%YU*Qp7cb#c#Kkw`MC_euB z)s>(7p8_jvRvYb}hh_@m1YYIuX4m=lSc%8qmuBYwoeaN<;yx68%txdHSzb3KUyt+r zm2?RU!CjR$^sNrO-JPeUSxPBm65rBq-(T)^zxnJ0LN~jd&Z8}PIMZmi%6x*Z{T~ + + + ) +} 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, + }, + } +})

sq@j4#83!c~m&ZwUgy-1jCS+~sK~ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..338e90d0fd359ba5da2ff517a1a4f32f0f40e78c GIT binary patch literal 2032 zcmV^_n??QOm%DfG4k{3jlarIEM=vEMC1hr1Qe0d-#B4Aa`0}6s7#jc3jtsz@Q01Z1 z=rRv;`}S>WYHCPLP36Lc3!FG{g7Wfm4*%|TLW+)?Q(=}iDu5|VW)PenJKJqF{bJ$KJ`nO=*ck#zwP7%n?TgAPFC;`#U8~CGyskr>BR;MTw{q=A-%YKis@|lfJ$_*_dxiDz?R^sCi;%WTL3d0w^pjBtJi& zBZm$V6dq1Qm?QTt9YLefFfcHHpPwJz-rjU~cVp4fj$QU2xvmYQKVmXx13V~>)Y2BM zC9u7f?iD2Wgk_Np0S?N(zi%YPe-Lv z(H0)bc`tvizO{+geR)jiXk$`!C2j-#bPmFpz{#f8#mrLyv{YB|@v4Bb@&p(dA#undcVKjfCqfZ!?q*`B1V|-AM)HQic z09-wLhTS`JSdsN19*&Om#b~g#wKWw~Cc2jR zl?B{WYtU@XGLbhd|#|K9N_{NPJJnyW+^AGRIyaR>-EG;eN{?jTG07j<9#zqEDe#L`9aC38`T^Osq zc9qNTq;ustZ>*mWrd0hC)Z5;dSE|fdgVzV^@p&^1=hs)#vwRf~vOmH4Mio*NHR^q` z9T7l9$!XLW7$dgv-Pc|vXWA5Q&z*(iUv}Wx(u^cHrM-n#0kEKVkalqt`_G@nr|~}e zsF_U9+=7LL1uelL`1<-jX?JKW=@}Xf@AO^zMmD0hw7~DH&zbtd3pnlEORtOTa5hu| z;6$;*7y99x@H*-Ack1;#eE1L>;X8Ieo%6##fnF%sy6*tRSaDRWE>?Jx{%Aff$@8gS zn9S{vu*X155+D~^TM#c^D?tgaEay^`7U$Smgl)(aGb5L$?!e~Fn~7PHOq#-uW74X` zHoMGvk*c+C!<`yzs;^NsK2Ut1krfBl)zJLerz0~wqL=`XBHaG{`%#EvrfhCv<5&e( zUQ8hVz^5pLBL*D(jFu^(IBncQg8=pdi zFMvgV-6MHqx)GVN~4a$4=|xhI^i&DM@KOC`4oPsucM zl?i~(&Q4k`e#4qF9T&&g(XuXs>gsAa;DjZoppJV9i+>l;aTl_wPFG^eV;8}H+C-VNJC4Ff z`WGZIZq;w;?d_Gsp8A>qu%8@E)vs6L>FGHnkei!Jc6K&v)~q2fFAtqg$DTcVsHmvG zH!u(hWLfz|W@E>4X{Ycms~XCOt>@Wh|V^dV(WbW8yFil`;6*hj>6Bd@)^Jplm$ z?AWn`y1F_hPMkhlgnoXAT@UN|sJe_h1E8$0L!{g|I5-$pjMyqhqzFQ1 z?k@DN{G6UsCvXrMkOWTJPD!z}`s?CCQiyz^DI$sw9o$J&I#OeRHmfldiex4xCPrv6 zU(5n9)ZL-ySU&osd5pJK5)l!>ty{NbWaMR&)t*B!i)93 zUBwIi5{Y3N9zkFi!Y}R;xw&5s~iuy?ggES7b`Va|P+zjk2;J z@7@CzFJ3%c)rftwJ75(Wj^oZ;q*eq1T3cJm%F05mRzIfD)zy`*t}dp!xzZ$#<-1$A z%DLiWOd|};0x-%9igdg=X&$Q5(=O~@9!^>367n~Zj}epF_kACRiick0{{U3{~W2|`2YX_21!IgR09B6Vn9z_E+-5C O0000f+2z|CH8pj$YHGT=YIHSK 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 C +