diff --git a/.env.example b/.env.example index 6721966..fed4a5b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # App PORT=3000 NODE_ENV=development +BUN_PUBLIC_BASE_URL=http://localhost:3000 # Dev Inspector REACT_EDITOR=code @@ -13,12 +14,20 @@ DIRECT_URL=postgresql://user:password@localhost:5432/base-template GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# Role +# Super Admin (comma-separated emails) SUPER_ADMIN_EMAIL=admin@example.com # API Key for external clients (e.g. mobile apps) API_KEY=your-secret-api-key-here -# Telegram Notification (optional) -TELEGRAM_NOTIFY_TOKEN= -TELEGRAM_NOTIFY_CHAT_ID= +# MinIO (object storage for bug report images) +MINIO_ENDPOINT= +MINIO_PORT=443 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET= +MINIO_UPLOAD_DIR=bug-reports + +# Redis (optional — enables App Logs feature on /dev) +REDIS_URL= diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..f9e5464 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "deploy-stg": { + "type": "stdio", + "command": "bun", + "args": ["scripts/mcp-deploy.ts"], + "env": { + "GH_TOKEN": "${GH_TOKEN}", + "STACK_NAME": "${STACK_NAME}", + "BASE_URL": "${BASE_URL}", + "STG_URL": "${STG_URL}", + "VERSION_PATH": "/api/system/version" + } + } + } +} diff --git a/Dockerfile b/Dockerfile index 923dcd9..90fbed2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ RUN bunx prisma generate # Build frontend (Vite → dist/) FROM prisma AS builder +ARG VITE_URL_API_DESA_PLUS +ENV VITE_URL_API_DESA_PLUS=$VITE_URL_API_DESA_PLUS COPY . . RUN bun run build diff --git a/bun.lock b/bun.lock index ced6c08..c1d46c1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,11 +10,17 @@ "@elysiajs/swagger": "^1.3.1", "@mantine/charts": "^9.0.0", "@mantine/core": "^8.3.18", + "@mantine/dates": "^9.1.1", "@mantine/hooks": "^8.3.18", + "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", + "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/client": "6", "@tanstack/react-query": "^5.95.2", "@tanstack/react-router": "^1.168.10", + "@xyflow/react": "^12.6.4", + "dayjs": "^1.11.20", + "elkjs": "^0.9.3", "elysia": "^1.4.28", "minio": "^8.0.7", "postcss": "^8.5.8", @@ -180,6 +186,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -198,12 +206,18 @@ "@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="], + "@mantine/dates": ["@mantine/dates@9.1.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "9.1.1", "@mantine/hooks": "9.1.1", "dayjs": ">=1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA=="], + "@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="], + "@mantine/modals": ["@mantine/modals@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q=="], + "@mantine/notifications": ["@mantine/notifications@8.3.18", "", { "dependencies": { "@mantine/store": "8.3.18", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw=="], "@mantine/store": ["@mantine/store@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], @@ -314,6 +328,8 @@ "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], @@ -322,12 +338,18 @@ "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -342,10 +364,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="], + + "@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -382,6 +414,8 @@ "block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="], @@ -392,8 +426,14 @@ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="], @@ -406,6 +446,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -418,12 +460,22 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -432,6 +484,10 @@ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], @@ -442,6 +498,8 @@ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], @@ -450,8 +508,14 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], @@ -464,6 +528,8 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -480,24 +546,40 @@ "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], "electron-to-chromium": ["electron-to-chromium@1.5.329", "", {}, "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ=="], + "elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -506,12 +588,22 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -520,8 +612,12 @@ "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], "fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="], @@ -536,16 +632,28 @@ "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + "get-intrinsic": ["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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], @@ -556,12 +664,24 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], @@ -584,14 +704,24 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "isbot": ["isbot@5.1.37", "", {}, "sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -624,8 +754,14 @@ "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -638,6 +774,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -650,8 +788,12 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], @@ -660,8 +802,14 @@ "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -672,6 +820,8 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], @@ -700,6 +850,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -710,8 +862,14 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -750,24 +908,48 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -780,6 +962,8 @@ "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], "stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="], @@ -822,6 +1006,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -830,6 +1016,8 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], @@ -838,6 +1026,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -856,6 +1046,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], @@ -864,6 +1056,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -888,6 +1082,10 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -904,6 +1102,8 @@ "@tanstack/router-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -916,6 +1116,10 @@ "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], @@ -928,14 +1132,20 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], @@ -950,8 +1160,16 @@ "@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="], + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@kitajs/ts-html-plugin/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/compose.yml b/compose.yml index aaaca7d..43b44b7 100644 --- a/compose.yml +++ b/compose.yml @@ -4,17 +4,30 @@ services: container_name: monitoring-app-stg restart: unless-stopped environment: + # App + - PORT=${PORT:-3000} + - NODE_ENV=${NODE_ENV:-production} + - BUN_PUBLIC_BASE_URL=${BUN_PUBLIC_BASE_URL} # Database - DATABASE_URL=${DATABASE_URL} - DIRECT_URL=${DIRECT_URL} # Google OAuth - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - # App - - PORT=${PORT:-3000} - - NODE_ENV=${NODE_ENV:-production} - # Admin (initial Super Admin emails, comma-separated) + # Super Admin - SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL} + # API Key + - API_KEY=${API_KEY} + # MinIO (object storage) + - MINIO_ENDPOINT=${MINIO_ENDPOINT} + - MINIO_PORT=${MINIO_PORT:-443} + - MINIO_USE_SSL=${MINIO_USE_SSL:-true} + - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} + - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} + - MINIO_BUCKET=${MINIO_BUCKET} + - MINIO_UPLOAD_DIR=${MINIO_UPLOAD_DIR:-bug-reports} + # Redis (optional — app logs feature) + - REDIS_URL=${REDIS_URL:-} networks: - public-net - postgres-net-stg diff --git a/package.json b/package.json index b490a5f..807f277 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "claude": "set -a && source .env && set +a && claude", "dev": "bun --watch src/serve.ts", "build": "vite build", "start": "NODE_ENV=production bun src/index.tsx", @@ -28,11 +29,17 @@ "@elysiajs/swagger": "^1.3.1", "@mantine/charts": "^9.0.0", "@mantine/core": "^8.3.18", + "@mantine/dates": "^9.1.1", "@mantine/hooks": "^8.3.18", + "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", + "@modelcontextprotocol/sdk": "^1.29.0", "@prisma/client": "6", "@tanstack/react-query": "^5.95.2", "@tanstack/react-router": "^1.168.10", + "@xyflow/react": "^12.6.4", + "dayjs": "^1.11.20", + "elkjs": "^0.9.3", "elysia": "^1.4.28", "minio": "^8.0.7", "postcss": "^8.5.8", diff --git a/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql new file mode 100644 index 0000000..b64087c --- /dev/null +++ b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql @@ -0,0 +1,21 @@ +-- AlterEnum: add USER back to Role +BEGIN; +CREATE TYPE "Role_new" AS ENUM ('USER', 'ADMIN', 'DEVELOPER'); +ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "user" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new"); +ALTER TYPE "Role" RENAME TO "Role_old"; +ALTER TYPE "Role_new" RENAME TO "Role"; +DROP TYPE "public"."Role_old"; +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'USER'; +COMMIT; + +-- AlterTable: make password nullable, change default role +ALTER TABLE "user" + ALTER COLUMN "password" DROP NOT NULL, + ALTER COLUMN "role" SET DEFAULT 'USER'; + +-- AlterTable: add googleId column +ALTER TABLE "user" ADD COLUMN "googleId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "user_googleId_key" ON "user"("googleId"); diff --git a/prisma/migrations/20260429074454_add_app_config/migration.sql b/prisma/migrations/20260429074454_add_app_config/migration.sql new file mode 100644 index 0000000..5f1770b --- /dev/null +++ b/prisma/migrations/20260429074454_add_app_config/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "app_config" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_config_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5667144..feb3cab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { } enum Role { + USER ADMIN DEVELOPER } @@ -41,8 +42,9 @@ model User { id String @id @default(uuid()) name String email String @unique - password String - role Role @default(ADMIN) + password String? + googleId String? @unique + role Role @default(USER) active Boolean @default(true) image String? createdAt DateTime @default(now()) @@ -143,6 +145,14 @@ model BugLog { @@map("bug_log") } + +model AppConfig { + key String @id + value String + updatedAt DateTime @updatedAt + + @@map("app_config") +} diff --git a/scripts/mcp-deploy.ts b/scripts/mcp-deploy.ts new file mode 100644 index 0000000..5b9c4f0 --- /dev/null +++ b/scripts/mcp-deploy.ts @@ -0,0 +1,231 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' + +const GH_TOKEN = process.env.GH_TOKEN ?? '' +const STACK_NAME = process.env.STACK_NAME ?? '' +const BASE_URL = process.env.BASE_URL ?? '' // https://api.github.com/repos/owner/repo +const STG_URL = process.env.STG_URL ?? '' // https://monitoring-stg.example.com +const VERSION_PATH = process.env.VERSION_PATH ?? '/api/system/version' + +// ─── GitHub API helpers ──────────────────────────────────────────────────────── + +const ghHeaders = { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +} + +async function triggerWorkflow(workflow: string, inputs: Record) { + const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/dispatches`, { + method: 'POST', + headers: ghHeaders, + body: JSON.stringify({ ref: 'main', inputs }), + }) + if (!res.ok) throw new Error(`GitHub API error ${res.status}: ${await res.text()}`) +} + +async function waitForWorkflow( + workflow: string, + afterTime: Date, + timeoutMs = 600_000, +): Promise<{ conclusion: string; url: string }> { + const deadline = Date.now() + timeoutMs + await Bun.sleep(8_000) // tunggu run muncul di API + + while (Date.now() < deadline) { + const res = await fetch(`${BASE_URL}/actions/workflows/${workflow}/runs?per_page=5`, { + headers: ghHeaders, + }) + const data = await res.json() as { workflow_runs: any[] } + const run = data.workflow_runs?.find( + (r: any) => new Date(r.created_at) >= afterTime, + ) + + if (run) { + if (run.status === 'completed') { + return { conclusion: run.conclusion ?? 'failure', url: run.html_url } + } + } + + await Bun.sleep(12_000) + } + + throw new Error(`Workflow ${workflow} timeout setelah ${timeoutMs / 1000}s`) +} + +// ─── Shell helpers ───────────────────────────────────────────────────────────── + +async function sh(cmd: string[]): Promise<{ out: string; err: string; ok: boolean }> { + const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', cwd: process.cwd() }) + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { out: out.trim(), err: err.trim(), ok: code === 0 } +} + +// ─── MCP Server ──────────────────────────────────────────────────────────────── + +const server = new McpServer({ name: 'deploy-stg', version: '1.0.0' }) + +// ─── Tool: publish (manual, single step) ────────────────────────────────────── + +server.tool( + 'publish', + 'Trigger publish.yml untuk build & push Docker image staging', + { tag: z.string().describe('Image tag, contoh: 1.0.0') }, + async ({ tag }) => { + await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) + return { content: [{ type: 'text', text: `✅ publish.yml dipicu → stg-${tag}` }] } + }, +) + +// ─── Tool: repull (manual, single step) ─────────────────────────────────────── + +server.tool( + 'repull', + 'Trigger re-pull.yml untuk redeploy stack staging di Portainer', + {}, + async () => { + await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) + return { content: [{ type: 'text', text: `✅ re-pull.yml dipicu → ${STACK_NAME}-stg` }] } + }, +) + +// ─── Tool: deploy (full pipeline) ───────────────────────────────────────────── + +server.tool( + 'deploy', + [ + 'Full deploy pipeline ke staging:', + '1. Cek pending migrations', + '2. Version bump di package.json', + '3. Commit & push ke build/stg', + '4. Trigger publish.yml → tunggu selesai', + '5. Trigger re-pull.yml → tunggu selesai', + '6. Cek version di staging & local untuk konfirmasi', + ].join('\n'), + { tag: z.string().describe('Versi baru, contoh: 1.2.3') }, + async ({ tag }) => { + const log: string[] = [] + + // ── 1. Cek & jalankan migrasi jika ada ───────────────────────────────── + const migrateStatus = await sh(['bunx', 'prisma', 'migrate', 'status']) + if (!migrateStatus.ok || migrateStatus.out.includes('not yet been applied')) { + log.push('⏳ Ada pending migrations — menjalankan migrate deploy...') + const migrateRun = await sh(['bunx', 'prisma', 'migrate', 'deploy']) + if (!migrateRun.ok) { + return { + content: [{ + type: 'text', + text: [ + ...log, + '❌ Migrate deploy gagal:', + migrateRun.err || migrateRun.out, + ].join('\n'), + }], + } + } + log.push('✅ Migrations: deployed') + } else { + log.push('✅ Migrations: up to date') + } + + // ── 2. Version bump ────────────────────────────────────────────────────── + const pkgPath = `${process.cwd()}/package.json` + const pkg = await Bun.file(pkgPath).json() + const prevVersion = pkg.version as string + pkg.version = tag + await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n') + log.push(`✅ Version bump: ${prevVersion} → ${tag}`) + + // ── 3. Commit & push build/stg ─────────────────────────────────────────── + await sh(['git', 'add', 'package.json']) + const commit = await sh(['git', 'commit', '-m', `chore: bump version to ${tag}`]) + if (!commit.ok) { + return { content: [{ type: 'text', text: `❌ git commit gagal:\n${commit.err}` }] } + } + log.push('✅ Committed') + + const push = await sh(['git', 'push', 'origin', 'HEAD:build/stg']) + if (!push.ok) { + return { content: [{ type: 'text', text: `❌ git push gagal:\n${push.err}` }] } + } + log.push('✅ Pushed → build/stg') + + // ── 4. Publish workflow ────────────────────────────────────────────────── + log.push('⏳ Menjalankan publish.yml...') + const publishTriggeredAt = new Date() + await triggerWorkflow('publish.yml', { stack_env: 'stg', tag }) + + const publish = await waitForWorkflow('publish.yml', publishTriggeredAt) + if (publish.conclusion !== 'success') { + return { + content: [{ + type: 'text', + text: [ + ...log, + `❌ publish.yml ${publish.conclusion}`, + `Detail: ${publish.url}`, + ].join('\n'), + }], + } + } + log.push(`✅ publish.yml sukses → ${publish.url}`) + + // ── 5. Re-pull workflow ────────────────────────────────────────────────── + log.push('⏳ Menjalankan re-pull.yml...') + const repullTriggeredAt = new Date() + await triggerWorkflow('re-pull.yml', { stack_name: STACK_NAME, stack_env: 'stg' }) + + const repull = await waitForWorkflow('re-pull.yml', repullTriggeredAt) + if (repull.conclusion !== 'success') { + return { + content: [{ + type: 'text', + text: [ + ...log, + `❌ re-pull.yml ${repull.conclusion}`, + `Detail: ${repull.url}`, + ].join('\n'), + }], + } + } + log.push(`✅ re-pull.yml sukses → ${repull.url}`) + + // ── 6. Cek version ─────────────────────────────────────────────────────── + await Bun.sleep(5_000) // tunggu container restart + log.push('⏳ Mengecek version di staging...') + + const localCommitProc = await sh(['git', 'rev-parse', '--short', 'HEAD']) + const localCommit = localCommitProc.out + + let stgInfo: { version?: string; commit?: string } = {} + try { + const versionRes = await fetch(`${STG_URL}${VERSION_PATH}`) + stgInfo = await versionRes.json() + } catch (e) { + log.push(`⚠️ Gagal mengecek version staging: ${e}`) + } + + const versionMatch = stgInfo.version === tag + const commitMatch = stgInfo.commit === localCommit + + log.push('') + log.push('─── Version Check ───────────────────────────') + log.push(`Local : version=${tag}, commit=${localCommit}`) + log.push(`Staging: version=${stgInfo.version ?? '?'}, commit=${stgInfo.commit ?? '?'}`) + log.push(versionMatch && commitMatch + ? '✅ Staging sudah terupdate dan sesuai local' + : `⚠️ Mismatch — version: ${versionMatch ? 'OK' : 'BEDA'}, commit: ${commitMatch ? 'OK' : 'BEDA'}`, + ) + + return { content: [{ type: 'text', text: log.join('\n') }] } + }, +) + +const transport = new StdioServerTransport() +await server.connect(transport) diff --git a/src/app.ts b/src/app.ts index be47ff6..8401f78 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,10 +3,21 @@ import { html } from '@elysiajs/html' import { swagger } from '@elysiajs/swagger' import { Elysia, t } from 'elysia' import { BugSource } from '../generated/prisma' +import { appLog, clearAppLogs, getAppLogs } from './lib/applog' import { prisma } from './lib/db' import { env } from './lib/env' import { createSystemLog } from './lib/logger' import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' +import { addConnection, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence' +import { parseSchema } from './lib/schema-parser' + +function getPublicOrigin(request: Request): string { + if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '') + const url = new URL(request.url) + const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host + return `${proto ?? url.protocol.replace(':', '')}://${host}` +} interface AuthResult { actingUserId: string @@ -35,6 +46,19 @@ async function checkAuth(request: Request): Promise { return null } +async function requireDeveloper(request: Request, set: { status?: number | string }): Promise<{ userId: string; role: string } | null> { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + if (!token) { set.status = 401; return null } + const session = await prisma.session.findUnique({ + where: { token }, + include: { user: { select: { id: true, role: true } } }, + }) + if (!session || session.expiresAt < new Date()) { set.status = 401; return null } + if (session.user.role !== 'DEVELOPER') { set.status = 403; return null } + return { userId: session.user.id, role: session.user.role } +} + export function createApp() { return new Elysia() .use(swagger({ @@ -55,6 +79,21 @@ export function createApp() { .use(cors()) .use(html()) + // ─── Request timing + app log broadcasting ──────── + .onRequest(({ request }) => { + ;(request as any).__startTime = performance.now() + }) + .onAfterResponse(({ request, set }) => { + const url = new URL(request.url) + if (url.pathname.startsWith('/api/')) { + const status = typeof set.status === 'number' ? set.status : 200 + const level = status >= 500 ? ('error' as const) : status >= 400 ? ('warn' as const) : ('info' as const) + appLog(level, `${request.method} ${url.pathname} ${status}`) + const duration = Math.round(performance.now() - ((request as any).__startTime || 0)) + broadcastToAdmins({ type: 'request', method: request.method, path: url.pathname, status, duration, timestamp: new Date().toISOString() }) + } + }) + // ─── Global Error Handler ──────────────────────── .onError(({ code, error }) => { if (code === 'NOT_FOUND') { @@ -80,13 +119,128 @@ export function createApp() { }) // ─── Auth API ────────────────────────────────────── + // ─── Google OAuth ────────────────────────────────── + .get('/api/auth/google', ({ request }) => { + const origin = getPublicOrigin(request) + const state = crypto.randomUUID() + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${origin}/api/auth/callback/google`, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'online', + prompt: 'select_account', + }) + const headers = new Headers() + headers.set('Location', `https://accounts.google.com/o/oauth2/v2/auth?${params}`) + headers.set('Set-Cookie', `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`) + return new Response(null, { status: 302, headers }) + }, { + detail: { + summary: 'Google OAuth Login', + description: 'Menginisiasi alur Google OAuth. Meredirect pengguna ke halaman login Google.', + tags: ['Auth'], + }, + }) + + .get('/api/auth/callback/google', async ({ query, request }) => { + const { code, state, error } = query as Record + const origin = getPublicOrigin(request) + + if (error) { + return new Response(null, { status: 302, headers: { Location: '/login?error=google_denied' } }) + } + + const cookie = request.headers.get('cookie') ?? '' + const storedState = cookie.match(/oauth_state=([^;]+)/)?.[1] + if (!state || !storedState || state !== storedState) { + return new Response(null, { status: 302, headers: { Location: '/login?error=invalid_state' } }) + } + + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: `${origin}/api/auth/callback/google`, + grant_type: 'authorization_code', + }), + }) + + if (!tokenRes.ok) { + return new Response(null, { status: 302, headers: { Location: '/login?error=token_failed' } }) + } + + const { access_token } = await tokenRes.json() as { access_token: string } + + const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` }, + }) + + if (!userInfoRes.ok) { + return new Response(null, { status: 302, headers: { Location: '/login?error=userinfo_failed' } }) + } + + const { id: googleId, email, name, picture } = await userInfoRes.json() as { + id: string; email: string; name: string; picture?: string + } + + let user = await prisma.user.findFirst({ + where: { OR: [{ googleId }, { email }] }, + }) + + if (!user) { + user = await prisma.user.create({ + data: { name, email, googleId, image: picture, role: 'USER' }, + }) + } else if (!user.googleId) { + user = await prisma.user.update({ + where: { id: user.id }, + data: { googleId, image: picture ?? user.image }, + }) + } + + if (!user.active) { + return new Response(null, { status: 302, headers: { Location: '/login?error=account_disabled' } }) + } + + if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { + user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) + } + + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) + await createSystemLog(user.id, 'LOGIN', 'Logged in with Google') + + const redirectPath = user.role === 'DEVELOPER' ? '/dev' : user.role === 'USER' ? '/profile' : '/dashboard' + const headers = new Headers() + headers.append('Location', redirectPath) + headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`) + headers.append('Set-Cookie', 'oauth_state=; Path=/; HttpOnly; Max-Age=0') + return new Response(null, { status: 302, headers }) + }, { + detail: { + summary: 'Google OAuth Callback', + description: 'Menerima callback dari Google, membuat/menautkan akun, membuat sesi, lalu meredirect ke /dashboard (ADMIN/DEVELOPER) atau /profile (USER baru).', + tags: ['Auth'], + }, + }) + .post('/api/auth/login', async ({ body, set }) => { const { email, password } = body let user = await prisma.user.findUnique({ where: { email } }) - if (!user || !(await Bun.password.verify(password, user.password))) { + if (!user || !user.password || !(await Bun.password.verify(password, user.password))) { set.status = 401 return { error: 'Email atau password salah' } } + if (!user.active) { + set.status = 403 + return { error: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.' } + } // Auto-promote super admin from env if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) @@ -96,7 +250,7 @@ export function createApp() { await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') - return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } + return { user: { id: user.id, name: user.name, email: user.email, role: user.role, image: user.image } } }, { body: t.Object({ email: t.String({ format: 'email', description: 'Email pengguna' }), @@ -135,13 +289,18 @@ export function createApp() { if (!token) { set.status = 401; return { user: null } } const session = await prisma.session.findUnique({ where: { token }, - include: { user: { select: { id: true, name: true, email: true, role: true } } }, + include: { user: { select: { id: true, name: true, email: true, role: true, image: true, active: true } } }, }) if (!session || session.expiresAt < new Date()) { if (session) await prisma.session.delete({ where: { id: session.id } }) set.status = 401 return { user: null } } + if (!session.user.active) { + await prisma.session.deleteMany({ where: { userId: session.user.id } }) + set.status = 401 + return { user: null } + } return { user: session.user } }, { detail: { @@ -268,6 +427,8 @@ export function createApp() { const search = query.search || '' const type = query.type as any const userId = query.userId + const dateFrom = query.dateFrom + const dateTo = query.dateTo const where: any = {} if (search) { @@ -282,6 +443,15 @@ export function createApp() { if (userId && userId !== 'all') { where.userId = userId } + if (dateFrom || dateTo) { + where.createdAt = {} + if (dateFrom) where.createdAt.gte = new Date(dateFrom) + if (dateTo) { + const end = new Date(dateTo) + end.setHours(23, 59, 59, 999) + where.createdAt.lte = end + } + } const [logs, total] = await Promise.all([ prisma.log.findMany({ @@ -306,6 +476,8 @@ export function createApp() { search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })), type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })), userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })), + dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (ISO string atau YYYY-MM-DD)' })), + dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (ISO string atau YYYY-MM-DD)' })), }), detail: { summary: 'List Activity Logs', @@ -454,7 +626,7 @@ export function createApp() { name: t.String({ minLength: 1, description: 'Nama lengkap operator' }), email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }), password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }), - role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }), + role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role untuk akun yang dibuat manual: ADMIN atau DEVELOPER' }), }), detail: { summary: 'Create Operator', @@ -482,6 +654,10 @@ export function createApp() { }, }) + if (body.active === false) { + await prisma.session.deleteMany({ where: { userId: id } }) + } + if (userId) { await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`) } @@ -494,7 +670,7 @@ export function createApp() { body: t.Object({ name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })), email: t.Optional(t.String({ format: 'email', description: 'Email baru' })), - role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), + role: t.Optional(t.Union([t.Literal('USER'), t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), active: t.Optional(t.Boolean({ description: 'Status aktif operator' })), }), detail: { @@ -834,6 +1010,39 @@ export function createApp() { tags: ['System'], }, }) + .get('/api/system/version', async () => { + const pkg = await Bun.file('./package.json').json() + let commit = 'unknown' + let branch = 'unknown' + let changelog: { hash: string; date: string; author: string; message: string }[] = [] + try { + const commitProc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }) + commit = (await new Response(commitProc.stdout).text()).trim() + const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }) + branch = (await new Response(branchProc.stdout).text()).trim() + const logProc = Bun.spawn( + ['git', 'log', '--pretty=format:%h|%aI|%an|%s', '-20'], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const logText = (await new Response(logProc.stdout).text()).trim() + changelog = logText.split('\n').filter(Boolean).map(line => { + const [hash, date, author, ...msgParts] = line.split('|') + return { hash, date, author, message: msgParts.join('|') } + }) + } catch { /* git not available */ } + return { + version: pkg.version as string, + commit, + branch, + changelog, + } + }, { + detail: { + summary: 'Version Info', + description: 'Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir sebagai changelog.', + tags: ['System'], + }, + }) // ─── Example API ─────────────────────────────────── .get('/api/hello', () => ({ @@ -856,4 +1065,545 @@ export function createApp() { }), detail: { summary: 'Hello by Name', tags: ['System'] }, }) + + // ─── Dev Console Admin API (DEVELOPER only) ──────── + + .get('/api/admin/stats', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const [totalApps, openBugs, totalOperators] = await Promise.all([ + prisma.app.count(), + prisma.bug.count({ where: { status: 'OPEN' } }), + prisma.user.count(), + ]) + const onlineCount = getOnlineUserIds().length + return { totalApps, openBugs, totalOperators, onlineOperators: onlineCount } + }) + + .get('/api/admin/users', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const users = await prisma.user.findMany({ + select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }) + return { users } + }) + + .put('/api/admin/users/:id/role', async ({ request, params, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah role sendiri' } } + const { role } = (await request.json()) as { role: string } + if (!['USER', 'ADMIN'].includes(role)) { set.status = 400; return { error: 'Role tidak valid (USER atau ADMIN)' } } + const target = await prisma.user.findUnique({ where: { id: params.id }, select: { role: true } }) + if (target?.role === 'DEVELOPER') { set.status = 400; return { error: 'Tidak bisa mengubah role DEVELOPER' } } + const user = await prisma.user.update({ + where: { id: params.id }, + data: { role: role as 'USER' | 'ADMIN' }, + select: { id: true, name: true, email: true, role: true, active: true, createdAt: true }, + }) + await appLog('info', `Role changed: ${user.email} ${target?.role} → ${role}`) + await createSystemLog(auth.userId, 'UPDATE', `Role changed: ${user.name} (${user.email}) ${target?.role} → ${role}`) + return { user } + }) + + .put('/api/admin/users/:id/activate', async ({ request, params, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah status sendiri' } } + const { active } = (await request.json()) as { active: boolean } + const user = await prisma.user.update({ + where: { id: params.id }, + data: { active }, + select: { id: true, name: true, email: true, role: true, active: true, createdAt: true }, + }) + if (!active) await prisma.session.deleteMany({ where: { userId: params.id } }) + await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`) + await createSystemLog(auth.userId, active ? 'UPDATE' : 'DELETE', `User ${active ? 'activated' : 'deactivated'}: ${user.name} (${user.email})`) + return { user } + }) + + .ws('/ws/presence', { + async open(ws) { + const cookie = ws.data.headers?.cookie ?? '' + const token = (cookie as string).match(/session=([^;]+)/)?.[1] + if (!token) { ws.close(4001, 'Unauthorized'); return } + const session = await prisma.session.findUnique({ + where: { token }, + include: { user: { select: { id: true, role: true } } }, + }) + if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return } + const isAdmin = session.user.role === 'DEVELOPER' + ;(ws.data as unknown as { userId: string }).userId = session.user.id + addConnection(ws as any, session.user.id, isAdmin) + }, + close(ws) { removeConnection(ws as any) }, + message() {}, + }) + + .get('/api/admin/presence', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + return { online: getOnlineUserIds() } + }) + + .get('/api/admin/logs/app', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const url = new URL(request.url) + const level = url.searchParams.get('level') as any + const limit = parseInt(url.searchParams.get('limit') ?? '100', 10) + const afterId = parseInt(url.searchParams.get('afterId') ?? '0', 10) + if (!env.REDIS_URL) return { logs: [], redisDisabled: true } + return { logs: await getAppLogs({ level: level || undefined, limit, afterId: afterId || undefined }) } + }) + + .delete('/api/admin/logs/app', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + await clearAppLogs() + return { ok: true } + }) + + .get('/api/admin/logs/audit', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const url = new URL(request.url) + const userId = url.searchParams.get('userId') + const type = url.searchParams.get('type') + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 500) + const where: Record = {} + if (userId) where.userId = userId + if (type) where.type = type + const logs = await prisma.log.findMany({ + where, + include: { user: { select: { name: true, email: true } } }, + orderBy: { createdAt: 'desc' }, + take: limit, + }) + return { logs } + }) + + .delete('/api/admin/logs/audit', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const { count } = await prisma.log.deleteMany() + await appLog('info', `Activity logs cleared manually (${count} entries)`) + return { ok: true, deleted: count } + }) + + .get('/api/admin/schema', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const fs = await import('node:fs') + const schemaPath = `${process.cwd()}/prisma/schema.prisma` + if (!fs.existsSync(schemaPath)) { set.status = 404; return { error: 'Schema not found' } } + const raw = fs.readFileSync(schemaPath, 'utf-8') + return { schema: parseSchema(raw) } + }) + + .get('/api/admin/routes', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const routes: { method: string; path: string; auth: string; category: string; description: string }[] = [ + { method: 'PAGE', path: '/', auth: 'public', category: 'frontend', description: 'Landing page' }, + { method: 'PAGE', path: '/login', auth: 'public', category: 'frontend', description: 'Login page (email/password + Google OAuth)' }, + { method: 'PAGE', path: '/dev', auth: 'developer', category: 'frontend', description: 'Dev console (DEVELOPER only)' }, + { method: 'PAGE', path: '/dashboard', auth: 'admin', category: 'frontend', description: 'Admin dashboard (ADMIN/DEVELOPER)' }, + { method: 'PAGE', path: '/apps', auth: 'admin', category: 'frontend', description: 'App list' }, + { method: 'PAGE', path: '/apps/:appId', auth: 'admin', category: 'frontend', description: 'App detail (errors, logs, users, etc.)' }, + { method: 'PAGE', path: '/bug-reports', auth: 'admin', category: 'frontend', description: 'Bug reports management' }, + { method: 'PAGE', path: '/logs', auth: 'admin', category: 'frontend', description: 'Activity logs' }, + { method: 'PAGE', path: '/users', auth: 'admin', category: 'frontend', description: 'Operator management' }, + { method: 'PAGE', path: '/profile', auth: 'authenticated', category: 'frontend', description: 'User profile' }, + { method: 'POST', path: '/api/auth/login', auth: 'public', category: 'auth', description: 'Email/password login' }, + { method: 'POST', path: '/api/auth/logout', auth: 'authenticated', category: 'auth', description: 'Logout' }, + { method: 'GET', path: '/api/auth/session', auth: 'public', category: 'auth', description: 'Check current session' }, + { method: 'GET', path: '/api/auth/google', auth: 'public', category: 'auth', description: 'Google OAuth redirect' }, + { method: 'GET', path: '/api/auth/callback/google', auth: 'public', category: 'auth', description: 'Google OAuth callback' }, + { method: 'GET', path: '/api/dashboard/stats', auth: 'authenticated', category: 'dashboard', description: 'Dashboard statistics' }, + { method: 'GET', path: '/api/dashboard/recent-errors', auth: 'authenticated', category: 'dashboard', description: 'Recent bug reports' }, + { method: 'GET', path: '/api/apps', auth: 'authenticated', category: 'apps', description: 'List monitored apps' }, + { method: 'GET', path: '/api/apps/:appId', auth: 'authenticated', category: 'apps', description: 'Get app details' }, + { method: 'GET', path: '/api/bugs', auth: 'authenticated', category: 'bugs', description: 'List bug reports' }, + { method: 'POST', path: '/api/bugs', auth: 'apiKeyOrSession', category: 'bugs', description: 'Create bug report' }, + { method: 'PATCH', path: '/api/bugs/:id/status', auth: 'authenticated', category: 'bugs', description: 'Update bug status' }, + { method: 'PATCH', path: '/api/bugs/:id/feedback', auth: 'authenticated', category: 'bugs', description: 'Update bug feedback' }, + { method: 'POST', path: '/api/upload/image', auth: 'apiKeyOrSession', category: 'bugs', description: 'Upload bug screenshot' }, + { method: 'GET', path: '/api/bugs/images', auth: 'public', category: 'bugs', description: 'Proxy bug image from MinIO' }, + { method: 'GET', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'List activity logs' }, + { method: 'POST', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'Create activity log' }, + { method: 'GET', path: '/api/logs/operators', auth: 'authenticated', category: 'logs', description: 'Operators list for log filter' }, + { method: 'GET', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'List operators' }, + { method: 'GET', path: '/api/operators/stats', auth: 'authenticated', category: 'operators', description: 'Operator stats' }, + { method: 'POST', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'Create operator' }, + { method: 'PATCH', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Update operator' }, + { method: 'DELETE', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Deactivate operator' }, + { method: 'GET', path: '/api/admin/stats', auth: 'developer', category: 'admin', description: 'Dev console overview stats' }, + { method: 'GET', path: '/api/admin/users', auth: 'developer', category: 'admin', description: 'List all users' }, + { method: 'PUT', path: '/api/admin/users/:id/role', auth: 'developer', category: 'admin', description: 'Change user role' }, + { method: 'PUT', path: '/api/admin/users/:id/activate', auth: 'developer', category: 'admin', description: 'Activate/deactivate user' }, + { method: 'GET', path: '/api/admin/presence', auth: 'developer', category: 'admin', description: 'Online user IDs' }, + { method: 'GET', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'App logs (Redis)' }, + { method: 'GET', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Activity logs (DB)' }, + { method: 'DELETE', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'Clear app logs' }, + { method: 'DELETE', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Clear activity logs' }, + { method: 'GET', path: '/api/admin/schema', auth: 'developer', category: 'admin', description: 'Database schema (Prisma)' }, + { method: 'GET', path: '/api/admin/routes', auth: 'developer', category: 'admin', description: 'Routes metadata' }, + { method: 'GET', path: '/api/admin/project-structure', auth: 'developer', category: 'admin', description: 'Project file structure' }, + { method: 'GET', path: '/api/admin/env-map', auth: 'developer', category: 'admin', description: 'Environment variables map' }, + { method: 'GET', path: '/api/admin/test-coverage', auth: 'developer', category: 'admin', description: 'Test coverage mapping' }, + { method: 'GET', path: '/api/admin/dependencies', auth: 'developer', category: 'admin', description: 'NPM dependencies graph' }, + { method: 'GET', path: '/api/admin/migrations', auth: 'developer', category: 'admin', description: 'Migration timeline' }, + { method: 'GET', path: '/api/admin/sessions', auth: 'developer', category: 'admin', description: 'Active sessions' }, + { method: 'WS', path: '/ws/presence', auth: 'authenticated', category: 'realtime', description: 'Real-time presence tracking' }, + { method: 'GET', path: '/health', auth: 'public', category: 'utility', description: 'Health check' }, + ] + const byMethod: Record = {} + const byAuth: Record = {} + const byCategory: Record = {} + for (const r of routes) { + byMethod[r.method] = (byMethod[r.method] || 0) + 1 + byAuth[r.auth] = (byAuth[r.auth] || 0) + 1 + byCategory[r.category] = (byCategory[r.category] || 0) + 1 + } + return { routes, summary: { total: routes.length, byMethod, byAuth, byCategory } } + }) + + .get('/api/admin/project-structure', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const fs = await import('node:fs') + const path = await import('node:path') + const root = process.cwd() + const scanDirs = ['src', 'prisma', 'tests'] + const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git', '.next']) + const exts = new Set(['.ts', '.tsx']) + interface FileInfo { path: string; category: string; lines: number; exports: string[]; imports: { from: string; names: string[] }[] } + interface DirInfo { path: string; category: string; fileCount: number } + const files: FileInfo[] = [] + const dirs: DirInfo[] = [] + function categorize(filePath: string): string { + if (filePath.startsWith('src/frontend/routes/')) return 'route' + if (filePath.startsWith('src/frontend/hooks/')) return 'hook' + if (filePath.startsWith('src/frontend/components/')) return 'component' + if (filePath.startsWith('src/frontend')) return 'frontend' + if (filePath.startsWith('src/lib/')) return 'lib' + if (filePath.startsWith('prisma/')) return 'prisma' + if (filePath.startsWith('tests/unit/')) return 'test-unit' + if (filePath.startsWith('tests/integration/')) return 'test-integration' + if (filePath.startsWith('tests/')) return 'test' + if (filePath.startsWith('src/')) return 'backend' + return 'config' + } + function parseFile(filePath: string, content: string): FileInfo { + const lines = content.split('\n').length + const exports: string[] = [] + const imports: { from: string; names: string[] }[] = [] + for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1]) + for (const m of content.matchAll(/import\s+(?:\{([^}]+)\}|(\w+))(?:\s*,\s*\{([^}]+)\})?\s+from\s+['"]([^'"]+)['"]/g)) { + const names: string[] = [] + if (m[1]) names.push(...m[1].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean)) + if (m[2]) names.push(m[2]) + if (m[3]) names.push(...m[3].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean)) + let from = m[4] + if (from.startsWith('.')) { + const dir = path.dirname(filePath) + from = path.normalize(path.join(dir, from)).replace(/\\/g, '/') + for (const ext of ['.ts', '.tsx', '/index.ts', '/index.tsx']) { + if (fs.existsSync(path.join(root, from + ext))) { from = from + ext; break } + if (fs.existsSync(path.join(root, from))) break + } + } + imports.push({ from, names }) + } + return { path: filePath, category: categorize(filePath), lines, exports, imports } + } + function scan(dir: string) { + const absDir = path.join(root, dir) + if (!fs.existsSync(absDir)) return + const entries = fs.readdirSync(absDir, { withFileTypes: true }) + let fileCount = 0 + for (const entry of entries) { + if (skipDirs.has(entry.name)) continue + const rel = path.join(dir, entry.name).replace(/\\/g, '/') + if (entry.isDirectory()) scan(rel) + else if (exts.has(path.extname(entry.name))) { files.push(parseFile(rel, fs.readFileSync(path.join(root, rel), 'utf-8'))); fileCount++ } + } + dirs.push({ path: dir, category: categorize(`${dir}/`), fileCount }) + } + for (const d of scanDirs) scan(d) + files.sort((a, b) => a.path.localeCompare(b.path)) + dirs.sort((a, b) => a.path.localeCompare(b.path)) + const totalLines = files.reduce((s, f) => s + f.lines, 0) + const totalExports = files.reduce((s, f) => s + f.exports.length, 0) + const totalImports = files.reduce((s, f) => s + f.imports.length, 0) + const byCategory: Record = {} + for (const f of files) byCategory[f.category] = (byCategory[f.category] || 0) + 1 + return { files, directories: dirs, summary: { totalFiles: files.length, totalLines, totalExports, totalImports, byCategory } } + }) + + .get('/api/admin/env-map', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const fs = await import('node:fs') + const path = await import('node:path') + const root = process.cwd() + const envDefs: { name: string; envKey: string; required: boolean; default: string | null; category: string; description: string }[] = [ + { name: 'DATABASE_URL', envKey: 'DATABASE_URL', required: true, default: null, category: 'database', description: 'PostgreSQL connection string' }, + { name: 'REDIS_URL', envKey: 'REDIS_URL', required: false, default: '(empty)', category: 'cache', description: 'Redis connection string (optional, enables App Logs)' }, + { name: 'GOOGLE_CLIENT_ID', envKey: 'GOOGLE_CLIENT_ID', required: true, default: null, category: 'auth', description: 'Google OAuth client ID' }, + { name: 'GOOGLE_CLIENT_SECRET', envKey: 'GOOGLE_CLIENT_SECRET', required: true, default: null, category: 'auth', description: 'Google OAuth client secret' }, + { name: 'SUPER_ADMIN_EMAIL', envKey: 'SUPER_ADMIN_EMAIL', required: false, default: '(empty)', category: 'auth', description: 'Emails to auto-promote to DEVELOPER role' }, + { name: 'API_KEY', envKey: 'API_KEY', required: true, default: null, category: 'auth', description: 'API key for external clients (mobile app)' }, + { name: 'MINIO_ENDPOINT', envKey: 'MINIO_ENDPOINT', required: true, default: null, category: 'storage', description: 'MinIO server endpoint' }, + { name: 'MINIO_PORT', envKey: 'MINIO_PORT', required: false, default: '443', category: 'storage', description: 'MinIO server port' }, + { name: 'MINIO_USE_SSL', envKey: 'MINIO_USE_SSL', required: false, default: 'true', category: 'storage', description: 'Use SSL for MinIO connection' }, + { name: 'MINIO_ACCESS_KEY', envKey: 'MINIO_ACCESS_KEY', required: true, default: null, category: 'storage', description: 'MinIO access key' }, + { name: 'MINIO_SECRET_KEY', envKey: 'MINIO_SECRET_KEY', required: true, default: null, category: 'storage', description: 'MinIO secret key' }, + { name: 'MINIO_BUCKET', envKey: 'MINIO_BUCKET', required: true, default: null, category: 'storage', description: 'MinIO bucket name' }, + { name: 'MINIO_UPLOAD_DIR', envKey: 'MINIO_UPLOAD_DIR', required: false, default: 'bug-reports', category: 'storage', description: 'MinIO upload directory prefix' }, + { name: 'PORT', envKey: 'PORT', required: false, default: '3000', category: 'app', description: 'Server port' }, + { name: 'NODE_ENV', envKey: 'NODE_ENV', required: false, default: 'development', category: 'app', description: 'Environment mode' }, + { name: 'REACT_EDITOR', envKey: 'REACT_EDITOR', required: false, default: 'code', category: 'app', description: 'Editor for click-to-source' }, + { name: 'BUN_PUBLIC_BASE_URL', envKey: 'BUN_PUBLIC_BASE_URL', required: false, default: 'http://localhost:3000', category: 'app', description: 'Public base URL (for OAuth redirect)' }, + ] + const srcFiles = ['src/lib/env.ts', 'src/lib/db.ts', 'src/lib/redis.ts', 'src/app.ts', 'src/index.tsx'] + const fileContents: Record = {} + for (const f of srcFiles) { + const absPath = path.join(root, f) + if (fs.existsSync(absPath)) fileContents[f] = fs.readFileSync(absPath, 'utf-8') + } + const variables = envDefs.map((def) => { + const usedBy: string[] = [] + for (const [file, content] of Object.entries(fileContents)) { + if (content.includes(def.envKey) || content.includes(`env.${def.name}`)) usedBy.push(file) + } + return { name: def.name, required: def.required, isSet: !!process.env[def.envKey], default: def.default, category: def.category, description: def.description, usedBy } + }) + const byCategory: Record = {} + let setCount = 0, requiredCount = 0 + for (const v of variables) { + byCategory[v.category] = (byCategory[v.category] || 0) + 1 + if (v.isSet) setCount++ + if (v.required) requiredCount++ + } + return { variables, summary: { total: variables.length, set: setCount, unset: variables.length - setCount, required: requiredCount, byCategory } } + }) + + .get('/api/admin/test-coverage', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const fs = await import('node:fs') + const pathMod = await import('node:path') + const root = process.cwd() + const exts = new Set(['.ts', '.tsx']) + const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git']) + interface SrcFile { path: string; lines: number; exports: string[]; testedBy: string[]; coverage: string } + interface TestFile { path: string; lines: number; type: string; targets: string[] } + function scanDir(dir: string, collect: string[]) { + const abs = pathMod.join(root, dir) + if (!fs.existsSync(abs)) return + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + if (skipDirs.has(entry.name)) continue + const rel = pathMod.join(dir, entry.name).replace(/\\/g, '/') + if (entry.isDirectory()) scanDir(rel, collect) + else if (exts.has(pathMod.extname(entry.name))) collect.push(rel) + } + } + const srcPaths: string[] = [] + scanDir('src', srcPaths) + const srcFiltered = srcPaths.filter((f) => !f.includes('routeTree.gen')) + const testPaths: string[] = [] + scanDir('tests', testPaths) + const testFiltered = testPaths.filter((f) => f.includes('.test.')) + const testFiles: TestFile[] = testFiltered.map((tp) => { + const content = fs.readFileSync(pathMod.join(root, tp), 'utf-8') + const lines = content.split('\n').length + const type = tp.includes('/unit/') ? 'unit' : tp.includes('/integration/') ? 'integration' : 'other' + const targets: string[] = [] + for (const m of content.matchAll(/from\s+['"]([^'"]*(?:src|lib)[^'"]*)['"]/g)) { + let resolved = m[1].replace(/^.*?src\//, 'src/') + if (resolved.startsWith('.')) resolved = pathMod.normalize(pathMod.join(pathMod.dirname(tp), resolved)).replace(/\\/g, '/') + for (const ext of ['', '.ts', '.tsx']) { + const full = resolved + ext + if (srcFiltered.includes(full)) { targets.push(full); break } + } + } + if (/fetch\(['"`]\/api\//.test(content) || /createApp|createTestApp/.test(content)) { + if (!targets.includes('src/app.ts')) targets.push('src/app.ts') + } + return { path: tp, lines, type, targets: [...new Set(targets)] } + }) + const testedByMap: Record = {} + for (const t of testFiles) for (const target of t.targets) { if (!testedByMap[target]) testedByMap[target] = []; testedByMap[target].push(t.path) } + const sourceFiles: SrcFile[] = srcFiltered.map((sp) => { + const content = fs.readFileSync(pathMod.join(root, sp), 'utf-8') + const lines = content.split('\n').length + const exports: string[] = [] + for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1]) + const tb = testedByMap[sp] || [] + const coverage = tb.length === 0 ? 'uncovered' : tb.some((t) => t.includes('/unit/')) ? 'covered' : 'partial' + return { path: sp, lines, exports, testedBy: tb, coverage } + }) + const covered = sourceFiles.filter((f) => f.coverage === 'covered').length + const partial = sourceFiles.filter((f) => f.coverage === 'partial').length + const uncovered = sourceFiles.filter((f) => f.coverage === 'uncovered').length + return { sourceFiles, testFiles, summary: { totalSource: sourceFiles.length, totalTests: testFiles.length, covered, partial, uncovered, coveragePercent: Math.round(((covered + partial * 0.5) / sourceFiles.length) * 100) } } + }) + + .get('/api/admin/dependencies', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const fs = await import('node:fs') + const pathMod = await import('node:path') + const root = process.cwd() + const pkgPath = pathMod.join(root, 'package.json') + if (!fs.existsSync(pkgPath)) { set.status = 404; return { error: 'package.json not found' } } + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) + const deps: Record = pkg.dependencies || {} + const devDeps: Record = pkg.devDependencies || {} + const catMap: Record = { + elysia: 'server', '@elysiajs/cors': 'server', '@elysiajs/html': 'server', '@elysiajs/swagger': 'server', + react: 'ui', 'react-dom': 'ui', '@mantine/core': 'ui', '@mantine/hooks': 'ui', '@mantine/charts': 'ui', + '@mantine/notifications': 'ui', '@mantine/modals': 'ui', '@tanstack/react-router': 'ui', + '@tanstack/react-query': 'ui', '@xyflow/react': 'ui', 'react-icons': 'ui', recharts: 'ui', swr: 'ui', + '@prisma/client': 'database', prisma: 'database', minio: 'storage', + vite: 'build', typescript: 'build', '@biomejs/biome': 'build', '@vitejs/plugin-react': 'build', elkjs: 'build', + } + const srcFiles: string[] = [] + function scanSrc(dir: string) { + const abs = pathMod.join(root, dir) + if (!fs.existsSync(abs)) return + for (const e of fs.readdirSync(abs, { withFileTypes: true })) { + if (['node_modules', 'dist', 'generated', '.git'].includes(e.name)) continue + const rel = pathMod.join(dir, e.name).replace(/\\/g, '/') + if (e.isDirectory()) scanSrc(rel) + else if (/\.(ts|tsx)$/.test(e.name)) srcFiles.push(rel) + } + } + scanSrc('src') + const fileContents: Record = {} + for (const f of srcFiles) fileContents[f] = fs.readFileSync(pathMod.join(root, f), 'utf-8') + const allPkgs: { name: string; version: string; type: string; category: string; usedBy: string[] }[] = [] + for (const [name, version] of Object.entries(deps)) { + const usedBy: string[] = [] + const importPattern = new RegExp(`from\\s+['"]${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + for (const [file, content] of Object.entries(fileContents)) if (importPattern.test(content)) usedBy.push(file) + allPkgs.push({ name, version, type: 'runtime', category: catMap[name] || 'other', usedBy }) + } + for (const [name, version] of Object.entries(devDeps)) allPkgs.push({ name, version, type: 'dev', category: catMap[name] || 'build', usedBy: [] }) + const byCategory: Record = {} + let runtime = 0, dev = 0 + for (const p of allPkgs) { byCategory[p.category] = (byCategory[p.category] || 0) + 1; if (p.type === 'runtime') runtime++; else dev++ } + return { packages: allPkgs, summary: { total: allPkgs.length, runtime, dev, byCategory } } + }) + + .get('/api/admin/migrations', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const fs = await import('node:fs') + const pathMod = await import('node:path') + const root = process.cwd() + const migrationsDir = pathMod.join(root, 'prisma/migrations') + if (!fs.existsSync(migrationsDir)) return { migrations: [], summary: { totalMigrations: 0, firstMigration: null, lastMigration: null, totalChanges: 0 } } + const entries = fs.readdirSync(migrationsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name)) + const migrations = entries.map((entry) => { + const sqlPath = pathMod.join(migrationsDir, entry.name, 'migration.sql') + let sql = '' + const changes: string[] = [] + if (fs.existsSync(sqlPath)) { + sql = fs.readFileSync(sqlPath, 'utf-8') + for (const m of sql.matchAll(/^(CREATE TABLE|ALTER TABLE|CREATE INDEX|CREATE UNIQUE INDEX|DROP TABLE|DROP INDEX|CREATE TYPE|ALTER TYPE)\s+["']?(\w+)["']?/gim)) changes.push(`${m[1]} ${m[2]}`) + for (const m of sql.matchAll(/CREATE TYPE\s+"(\w+)"/g)) if (!changes.some((c) => c.includes(m[1]))) changes.push(`CREATE TYPE ${m[1]}`) + } + const dateStr = entry.name.substring(0, 14) + const createdAt = new Date(`${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}T${dateStr.slice(8, 10)}:${dateStr.slice(10, 12)}:${dateStr.slice(12, 14)}.000Z`).toISOString() + const name = entry.name.substring(15) + return { name, folder: entry.name, createdAt, changes, sql: sql.substring(0, 800) } + }) + const totalChanges = migrations.reduce((s, m) => s + m.changes.length, 0) + return { migrations, summary: { totalMigrations: migrations.length, firstMigration: migrations[0]?.createdAt || null, lastMigration: migrations[migrations.length - 1]?.createdAt || null, totalChanges } } + }) + + .get('/api/admin/sessions', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const onlineIds = new Set(getOnlineUserIds()) + const sessions = await prisma.session.findMany({ + include: { user: { select: { id: true, name: true, email: true, role: true, active: true } } }, + orderBy: { createdAt: 'desc' }, + }) + const now = new Date() + const result = sessions.map((s) => ({ + id: s.id, userId: s.user.id, userName: s.user.name, userEmail: s.user.email, + userRole: s.user.role, userActive: s.user.active, + isOnline: onlineIds.has(s.user.id), + createdAt: s.createdAt.toISOString(), expiresAt: s.expiresAt.toISOString(), isExpired: s.expiresAt < now, + })) + const byRole: Record = {} + const uniqueUsers = new Set() + let active = 0, expired = 0 + for (const s of result) { + uniqueUsers.add(s.userId) + byRole[s.userRole] = (byRole[s.userRole] || 0) + 1 + if (s.isExpired) expired++; else active++ + } + return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } } + }) + + // ─── App Config ──────────────────────────────────────────────────────────── + + .get('/api/admin/config', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const configs = await prisma.appConfig.findMany({ orderBy: { key: 'asc' } }) + return { configs: configs.map((c) => ({ key: c.key, value: c.value, updatedAt: c.updatedAt.toISOString() })) } + }, { + detail: { summary: 'Get App Config', tags: ['Admin'] }, + }) + + .put('/api/admin/config', async ({ request, set }) => { + const auth = await requireDeveloper(request, set) + if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' } + const body = await request.json() as { key: string; value: string } + if (!body.key || typeof body.value !== 'string') { set.status = 400; return { error: 'key and value required' } } + const config = await prisma.appConfig.upsert({ + where: { key: body.key }, + update: { value: body.value }, + create: { key: body.key, value: body.value }, + }) + await createSystemLog(auth.userId, 'UPDATE', `Updated app config: ${body.key}`) + return { key: config.key, value: config.value, updatedAt: config.updatedAt.toISOString() } + }, { + detail: { summary: 'Update App Config', tags: ['Admin'] }, + }) + + // ─── Desa Plus Proxy ─────────────────────────────────────────────────────── + + .all('/api/proxy/desa-plus/*', async ({ request, set }) => { + const [baseConfig, apiKeyConfig] = await Promise.all([ + prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } }), + prisma.appConfig.findUnique({ where: { key: 'API_KEY_DESA_PLUS' } }), + ]) + if (!baseConfig?.value) { set.status = 503; return { error: 'URL_API_DESA_PLUS belum dikonfigurasi. Set di /dev → Settings.' } } + const base = baseConfig.value.replace(/\/$/, '') + const url = new URL(request.url) + const upstream = `${base}${url.pathname.replace('/api/proxy/desa-plus', '')}${url.search}` + const headers = new Headers(request.headers) + headers.delete('host') + if (apiKeyConfig?.value) headers.set('X-API-Key', apiKeyConfig.value) + try { + const res = await fetch(upstream, { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined }) + const contentType = res.headers.get('content-type') ?? 'application/json' + set.status = res.status + return new Response(res.body, { status: res.status, headers: { 'content-type': contentType } }) + } catch (e) { + set.status = 502 + return { error: 'Gagal menghubungi API desa-plus', detail: String(e) } + } + }, { + detail: { summary: 'Proxy Desa Plus API', tags: ['Proxy'] }, + }) } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 453a205..7f8c651 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -1,6 +1,8 @@ import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core' import '@mantine/core/styles.css' +import '@mantine/dates/styles.css' import '@mantine/notifications/styles.css' +import { ModalsProvider } from '@mantine/modals' import { Notifications } from '@mantine/notifications' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRouter, RouterProvider } from '@tanstack/react-router' @@ -64,9 +66,11 @@ export function App() { - - - + + + + + ) diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 6891f49..9470c48 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -1,20 +1,25 @@ import { APP_CONFIGS } from '@/frontend/config/appMenus' import { useLogout, useSession } from '@/frontend/hooks/useAuth' +import React from 'react' import { ActionIcon, + Alert, AppShell, Avatar, Box, Burger, Button, + Center, Group, Loader, + LoadingOverlay, Menu, NavLink, Select, Stack, Text, ThemeIcon, + Title, useComputedColorScheme, useMantineColorScheme } from '@mantine/core' @@ -26,6 +31,7 @@ import { TbApps, TbArrowLeft, TbChevronRight, + TbClock, TbDashboard, TbDeviceMobile, TbHistory, @@ -54,10 +60,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const currentPath = matches[matches.length - 1]?.pathname // ─── Connect to auth system ────────────────────────── - const { data: sessionData } = useSession() + const { data: sessionData, isLoading: sessionLoading } = useSession() const user = sessionData?.user const logout = useLogout() + // Redirect USER role to profile (pending approval) + React.useEffect(() => { + if (!sessionLoading && user?.role === 'USER') { + navigate({ to: '/profile' }) + } + }, [user?.role, sessionLoading, navigate]) + // ─── Fetch registered apps from database ───────────── const { data: appsData } = useQuery({ queryKey: ['apps'], @@ -99,6 +112,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { logout.mutate() } + // Prevent dashboard flash for USER role while redirect is happening + if (sessionLoading || user?.role === 'USER') { + return ( +
+ +
+ ) + } + return ( Profile - } - onClick={() => navigate({ to: '/dashboard' })} - > - Settings - Danger Zone - `${API_BASE_URL}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`, - infoVillages: (id: string) => - `${API_BASE_URL}/api/monitoring/info-villages?id=${id}`, - gridVillages: (id: string) => - `${API_BASE_URL}/api/monitoring/grid-villages?id=${id}`, - graphLogVillages: (id: string, time: string) => - `${API_BASE_URL}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, - getUsers: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, - getLogsAllVillages: (page: number, search: string) => - `${API_BASE_URL}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, - getGridOverview: () => `${API_BASE_URL}/api/monitoring/grid-overview`, - getDailyActivity: () => `${API_BASE_URL}/api/monitoring/daily-activity`, - getComparisonActivity: () => `${API_BASE_URL}/api/monitoring/comparison-activity`, - postVersionUpdate: () => `${API_BASE_URL}/api/monitoring/version-update`, - createVillages: () => `${API_BASE_URL}/api/monitoring/create-villages`, - createUser: () => `${API_BASE_URL}/api/monitoring/create-user`, - listRole: () => `${API_BASE_URL}/api/monitoring/list-userrole-villages`, - listGroup: (id: string) => `${API_BASE_URL}/api/monitoring/list-group-villages?id=${id}`, - listPosition: (id: string) => `${API_BASE_URL}/api/monitoring/list-position-villages?id=${id}`, - editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`, - updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`, - editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`, - getGlobalLogs: (page: number, search: string, type: string, userId: string) => - `/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`, + getVillages: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/get-villages?page=${page}&search=${encodeURIComponent(search)}`, + infoVillages: (id: string) => + `${DESA_PLUS_PROXY}/api/monitoring/info-villages?id=${id}`, + gridVillages: (id: string) => + `${DESA_PLUS_PROXY}/api/monitoring/grid-villages?id=${id}`, + graphLogVillages: (id: string, time: string) => + `${DESA_PLUS_PROXY}/api/monitoring/graph-log-villages?id=${id}&time=${time}`, + getUsers: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/user?page=${page}&search=${encodeURIComponent(search)}`, + getLogsAllVillages: (page: number, search: string) => + `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?page=${page}&search=${encodeURIComponent(search)}`, + getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`, + getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`, + getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`, + postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`, + createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`, + createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`, + listRole: () => `${DESA_PLUS_PROXY}/api/monitoring/list-userrole-villages`, + listGroup: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-group-villages?id=${id}`, + listPosition: (id: string) => `${DESA_PLUS_PROXY}/api/monitoring/list-position-villages?id=${id}`, + editUser: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-user`, + updateStatusVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/update-status-villages`, + editVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/edit-villages`, + getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => { + const params = new URLSearchParams({ page: String(page), search, type, userId }) + if (dateFrom) params.set('dateFrom', dateFrom) + if (dateTo) params.set('dateTo', dateTo) + return `/api/logs?${params}` + }, getLogOperators: () => `/api/logs/operators`, getOperators: (page: number, search: string) => `/api/operators?page=${page}&search=${encodeURIComponent(search)}`, diff --git a/src/frontend/hooks/useAuth.ts b/src/frontend/hooks/useAuth.ts index de4e7c3..de18fcd 100644 --- a/src/frontend/hooks/useAuth.ts +++ b/src/frontend/hooks/useAuth.ts @@ -1,13 +1,20 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -export type Role = | 'ADMIN' | 'DEVELOPER' +export type Role = 'USER' | 'ADMIN' | 'DEVELOPER' + +export function getDefaultRoute(role: Role): string { + if (role === 'DEVELOPER') return '/dev' + if (role === 'ADMIN') return '/dashboard' + return '/profile' +} export interface User { id: string name: string email: string role: Role + image?: string | null } async function apiFetch(path: string, init?: RequestInit): Promise { @@ -41,7 +48,7 @@ export function useLogin() { }), onSuccess: (data) => { queryClient.setQueryData(['auth', 'session'], data) - navigate({ to: '/dashboard' }) + navigate({ to: getDefaultRoute(data.user.role) }) }, }) } diff --git a/src/frontend/hooks/usePresence.ts b/src/frontend/hooks/usePresence.ts new file mode 100644 index 0000000..4ab296e --- /dev/null +++ b/src/frontend/hooks/usePresence.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from 'react' +import { useSession } from './useAuth' + +export function usePresence() { + const { data } = useSession() + const [onlineUserIds, setOnlineUserIds] = useState([]) + const wsRef = useRef(null) + const reconnectTimer = useRef | undefined>(undefined) + + useEffect(() => { + if (!data?.user) return + + function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${location.host}/ws/presence`) + wsRef.current = ws + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + if (msg.type === 'presence') setOnlineUserIds(msg.online) + } + ws.onclose = () => { + wsRef.current = null + reconnectTimer.current = setTimeout(connect, 3000) + } + ws.onerror = () => ws.close() + } + + connect() + + return () => { + clearTimeout(reconnectTimer.current) + if (wsRef.current) { + wsRef.current.onclose = null + wsRef.current.close() + wsRef.current = null + } + } + }, [data?.user?.id, data?.user]) + + return { onlineUserIds } +} diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx new file mode 100644 index 0000000..e4e2ccf --- /dev/null +++ b/src/frontend/routes/dev.tsx @@ -0,0 +1,1599 @@ +import { + ActionIcon, + AppShell, + Avatar, + Badge, + Box, + Burger, + Button, + Card, + Center, + Container, + Group, + Loader, + Menu, + Modal, + NavLink, + Pagination, + Paper, + SegmentedControl, + Select, + SimpleGrid, + Stack, + Table, + Text, + ThemeIcon, + Title, + Tooltip, +} from '@mantine/core' +import { useDisclosure, useMediaQuery } from '@mantine/hooks' +import { modals } from '@mantine/modals' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router' +import { + Background, + Controls, + type Edge, + Handle, + MarkerType, + type Node, + Position, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import ELK from 'elkjs/lib/elk.bundled.js' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + TbActivity, + TbApps, + TbBug, + TbChevronRight, + TbCircleFilled, + TbCode, + TbDatabase, + TbDots, + TbFileText, + TbLayoutDashboard, + TbLayoutSidebarLeftCollapse, + TbLayoutSidebarLeftExpand, + TbLogout, + TbRefresh, + TbServer, + TbSettings, + TbSitemap, + TbTrash, + TbUser, + TbUserSearch, + TbUsers, +} from 'react-icons/tb' +import { notifications } from '@mantine/notifications' +import { type Role, useLogout, useSession } from '@/frontend/hooks/useAuth' +import { usePresence } from '@/frontend/hooks/usePresence' + +const validTabs = ['overview', 'operators', 'bugs', 'app-logs', 'activity-logs', 'database', 'project', 'settings'] as const + +export const Route = createFileRoute('/dev')({ + validateSearch: (search: Record) => ({ + tab: validTabs.includes(search.tab as any) ? (search.tab as string) : 'overview', + }), + beforeLoad: async ({ context }) => { + try { + const data = await context.queryClient.ensureQueryData({ + queryKey: ['auth', 'session'], + queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), + }) + if (!data?.user) throw redirect({ to: '/login' }) + if (data.user.role !== 'DEVELOPER') throw redirect({ to: '/dashboard' }) + } catch (e) { + if (e instanceof Error) throw redirect({ to: '/login' }) + throw e + } + }, + component: DevPage, +}) + +interface AdminUser { + id: string + name: string + email: string + role: Role + active: boolean + image?: string | null + createdAt: string +} + +const navItems = [ + { label: 'Overview', icon: TbLayoutDashboard, key: 'overview' }, + { label: 'Operators', icon: TbUsers, key: 'operators' }, + { label: 'Bugs', icon: TbBug, key: 'bugs' }, + { label: 'App Logs', icon: TbServer, key: 'app-logs', disabled: true }, + // { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' }, + { label: 'Database', icon: TbDatabase, key: 'database' }, + { label: 'Project', icon: TbSitemap, key: 'project' }, + { label: 'Settings', icon: TbSettings, key: 'settings' }, +] + +function DevPage() { + const { data } = useSession() + const logout = useLogout() + const user = data?.user + const { tab: active } = Route.useSearch() + const navigate = useNavigate() + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = useDisclosure(false) + const isMobile = useMediaQuery('(max-width: 48em)') + const setActive = (key: string) => { + navigate({ to: '/dev', search: { tab: key } }) + closeMobile() + } + const [collapsed, setCollapsed] = useState(() => localStorage.getItem('dev:sidebar') === 'collapsed') + const toggleSidebar = () => { + setCollapsed((prev) => { + const next = !prev + localStorage.setItem('dev:sidebar', next ? 'collapsed' : 'open') + return next + }) + } + const confirmLogout = () => + modals.openConfirmModal({ + title: 'Logout', + children: Yakin ingin logout?, + labels: { confirm: 'Logout', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => logout.mutate(), + }) + + return ( + + + + + + + + + Dev Console + + + + + + + + {collapsed ? ( + + + + + + ) : ( + <> + + + + +
+ Dev Console + Developer +
+
+ + + + + + + )} +
+
+ + + + {navItems.map((item) => { + const Icon = item.icon + if (collapsed) { + return ( + + !item.disabled && setActive(item.key)} + > + + + + ) + } + return ( + } + rightSection={active === item.key ? : undefined} + active={active === item.key} + disabled={item.disabled} + onClick={() => !item.disabled && setActive(item.key)} + style={{ borderRadius: 6 }} + /> + ) + })} + + + + + {!collapsed && user && ( + + + + {user.name.charAt(0).toUpperCase()} + + + {user.name} + {user.email} + + + + )} + + {!collapsed && ( + + + + + + )} + {collapsed && ( + + + + + + )} + + +
+ + + + {active === 'overview' && } + {active === 'operators' && } + {active === 'bugs' && } + {active === 'app-logs' && } + {active === 'activity-logs' && } + {active === 'database' && } + {active === 'project' && } + {active === 'settings' && } + + +
+ ) +} + +// ─── Overview Panel ──────────────────────────────────────────────────────────── + +function OverviewPanel() { + const { onlineUserIds } = usePresence() + const { data } = useQuery({ + queryKey: ['admin', 'stats'], + queryFn: () => fetch('/api/admin/stats', { credentials: 'include' }).then((r) => r.json()), + refetchInterval: 10_000, + }) + const stats = data ?? {} + + const cards = [ + { label: 'Total Apps', value: stats.totalApps ?? '—', icon: TbApps, color: 'blue' }, + { label: 'Open Bugs', value: stats.openBugs ?? '—', icon: TbBug, color: 'red' }, + { label: 'Total Operators', value: stats.totalOperators ?? '—', icon: TbUsers, color: 'green' }, + { label: 'Online Now', value: onlineUserIds.length, icon: TbCircleFilled, color: 'teal' }, + ] + + return ( + + Overview + + {cards.map((c) => { + const Icon = c.icon + return ( + + + {c.label} + + + + + {String(c.value)} + + ) + })} + + + ) +} + +// ─── Operators Panel ─────────────────────────────────────────────────────────── + +function OperatorsPanel({ currentUserId }: { currentUserId: string }) { + const { onlineUserIds } = usePresence() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'users'], + queryFn: () => fetch('/api/admin/users', { credentials: 'include' }).then((r) => r.json()), + }) + const users: AdminUser[] = data?.users ?? [] + + const roleMutation = useMutation({ + mutationFn: ({ id, role }: { id: string; role: string }) => + fetch(`/api/admin/users/${id}/role`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role }) }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), + }) + const activateMutation = useMutation({ + mutationFn: ({ id, active }: { id: string; active: boolean }) => + fetch(`/api/admin/users/${id}/activate`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active }) }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), + }) + + const roleColor: Record = { DEVELOPER: 'violet', ADMIN: 'blue', USER: 'gray' } + + return ( + + + Operators + {users.length} total + + {isLoading ?
: ( + + + + + Operator + Role + Status + Joined + + + + + {users.map((u) => { + const isOnline = onlineUserIds.includes(u.id) + const isSelf = u.id === currentUserId + const isDeveloper = u.role === 'DEVELOPER' + return ( + + + + + {u.name.charAt(0).toUpperCase()} + {isOnline && ( + + )} + +
+ {u.name} {isSelf && (you)} + {u.email} +
+
+
+ {u.role} + + {!u.active ? Inactive + : isOnline ? Online + : Offline} + + {new Date(u.createdAt).toLocaleDateString('id-ID')} + + {!isSelf && !isDeveloper && ( + + + + + + Ganti Role + {(['USER', 'ADMIN'] as const).filter((r) => r !== u.role).map((r) => ( + } onClick={() => roleMutation.mutate({ id: u.id, role: r })}> + Jadikan {r} + + ))} + + {u.active ? ( + } + onClick={() => modals.openConfirmModal({ + title: 'Nonaktifkan Operator', + children: Nonaktifkan {u.name}? Semua sesi aktif akan dihapus., + labels: { confirm: 'Nonaktifkan', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => activateMutation.mutate({ id: u.id, active: false }), + })}> + Nonaktifkan + + ) : ( + } + onClick={() => activateMutation.mutate({ id: u.id, active: true })}> + Aktifkan + + )} + + + )} + +
+ ) + })} +
+
+
+ )} +
+ ) +} + +// ─── Bugs Panel ──────────────────────────────────────────────────────────────── + +const BUG_STATUSES = ['all', 'OPEN', 'ON_HOLD', 'IN_PROGRESS', 'RESOLVED', 'RELEASED', 'CLOSED'] as const +const BUG_STATUS_COLOR: Record = { + OPEN: 'red', ON_HOLD: 'orange', IN_PROGRESS: 'blue', RESOLVED: 'teal', RELEASED: 'green', CLOSED: 'gray', +} +const BUG_SOURCE_COLOR: Record = { QC: 'violet', SYSTEM: 'blue', USER: 'gray' } + +function BugsPanel() { + const [status, setStatus] = useState('all') + const [page, setPage] = useState(1) + const PER_PAGE = 25 + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'bugs', status, page], + queryFn: () => { + const params = new URLSearchParams({ page: String(page), limit: String(PER_PAGE) }) + if (status !== 'all') params.set('status', status) + return fetch(`/api/bugs?${params}`, { credentials: 'include' }).then((r) => r.json()) + }, + refetchInterval: 15_000, + }) + + const bugs = data?.data ?? [] + const totalPages = data?.totalPages ?? 1 + const totalItems = data?.totalItems ?? 0 + + return ( + + + Bugs + {totalItems} total + + { setStatus(v); setPage(1) }} + data={BUG_STATUSES.map((s) => ({ label: s === 'all' ? 'All' : s.replace('_', ' '), value: s }))} + size="xs" + /> + {isLoading ?
: ( + <> + + + + + Status + Source + App + Description + Version + Reporter + Date + + + + {bugs.map((b: any) => ( + + {b.status.replace('_', ' ')} + {b.source} + {b.app?.name ?? b.appId ?? '—'} + {b.description} + {b.affectedVersion} + {b.user?.name ?? '—'} + {new Date(b.createdAt).toLocaleDateString('id-ID')} + + ))} + {bugs.length === 0 && ( + +
Tidak ada bug ditemukan
+
+ )} +
+
+
+ {totalPages > 1 &&
} + + )} +
+ ) +} + +// ─── App Logs Panel ──────────────────────────────────────────────────────────── + +const LOG_LEVELS = ['all', 'info', 'warn', 'error'] as const +const LOG_LEVEL_COLOR: Record = { info: 'blue', warn: 'orange', error: 'red' } + +function AppLogsPanel() { + const [level, setLevel] = useState('all') + const [page, setPage] = useState(1) + const PER_PAGE = 25 + const qc = useQueryClient() + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'logs', 'app', level], + queryFn: () => { + const params = new URLSearchParams({ limit: '200' }) + if (level !== 'all') params.set('level', level) + return fetch(`/api/admin/logs/app?${params}`, { credentials: 'include' }).then((r) => r.json()) + }, + refetchInterval: 5_000, + }) + + const allLogs = data?.logs ?? [] + const redisDisabled = data?.redisDisabled + + const pageLogs = useMemo(() => { + const start = (page - 1) * PER_PAGE + return allLogs.slice(start, start + PER_PAGE) + }, [allLogs, page]) + + const totalPages = Math.ceil(allLogs.length / PER_PAGE) + + const clearMutation = useMutation({ + mutationFn: () => fetch('/api/admin/logs/app', { method: 'DELETE', credentials: 'include' }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'logs', 'app'] }), + }) + + return ( + + + App Logs + + qc.invalidateQueries({ queryKey: ['admin', 'logs', 'app'] })}> + + + modals.openConfirmModal({ + title: 'Hapus semua app logs', + children: Semua log Redis akan dihapus. Tindakan ini tidak bisa dibatalkan., + labels: { confirm: 'Hapus', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => clearMutation.mutate(), + })}> + + + + + + {redisDisabled && ( + + Redis tidak dikonfigurasi (REDIS_URL kosong). App Logs tidak tersedia. + + )} + + { setLevel(v); setPage(1) }} + data={LOG_LEVELS.map((l) => ({ label: l === 'all' ? 'All' : l.toUpperCase(), value: l }))} + size="xs" + /> + + {isLoading ?
: ( + <> + + + + + Time + Level + Message + Detail + + + + {pageLogs.map((log: any) => ( + + {new Date(log.timestamp).toLocaleTimeString('id-ID')} + {log.level.toUpperCase()} + {log.message} + {log.detail ?? ''} + + ))} + {pageLogs.length === 0 && !redisDisabled && ( +
Belum ada log
+ )} +
+
+
+ {totalPages > 1 &&
} + + )} +
+ ) +} + +// ─── Activity Logs Panel ─────────────────────────────────────────────────────── + +const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const +const LOG_TYPE_COLOR: Record = { LOGIN: 'green', LOGOUT: 'gray', CREATE: 'blue', UPDATE: 'yellow', DELETE: 'red' } + +function ActivityLogsPanel() { + const [type, setType] = useState('all') + const [userId, setUserId] = useState('all') + const [page, setPage] = useState(1) + const PER_PAGE = 25 + const qc = useQueryClient() + + const { data: usersData } = useQuery({ + queryKey: ['admin', 'users'], + queryFn: () => fetch('/api/admin/users', { credentials: 'include' }).then((r) => r.json()), + }) + const users: AdminUser[] = usersData?.users ?? [] + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'logs', 'audit', type, userId], + queryFn: () => { + const params = new URLSearchParams({ limit: '200' }) + if (type !== 'all') params.set('type', type) + if (userId !== 'all') params.set('userId', userId) + return fetch(`/api/admin/logs/audit?${params}`, { credentials: 'include' }).then((r) => r.json()) + }, + refetchInterval: 10_000, + }) + + const allLogs = data?.logs ?? [] + const pageLogs = useMemo(() => { + const start = (page - 1) * PER_PAGE + return allLogs.slice(start, start + PER_PAGE) + }, [allLogs, page]) + const totalPages = Math.ceil(allLogs.length / PER_PAGE) + + const clearMutation = useMutation({ + mutationFn: () => fetch('/api/admin/logs/audit', { method: 'DELETE', credentials: 'include' }).then((r) => r.json()), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'logs', 'audit'] }), + }) + + return ( + + + Activity Logs + + qc.invalidateQueries({ queryKey: ['admin', 'logs', 'audit'] })}> + + + modals.openConfirmModal({ + title: 'Hapus semua activity logs', + children: Semua log aktivitas akan dihapus permanen., + labels: { confirm: 'Hapus', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => clearMutation.mutate(), + })}> + + + + + + v && setView(v)} + data={PROJECT_VIEWS.map((g) => ({ group: g.group, items: g.items }))} + size="xs" + w={200} + /> + + + {isLiveView && } + {isStaticView && staticGraph && ( + + + + )} + {!isLiveView && !isStaticView && viewData && ( + + + + )} + + + ) +} + +function GenericFlowPanelWrapper(props: { queryKey: string[]; queryFn: () => Promise; buildGraph: (d: any) => { nodes: Node[]; edges: Edge[] } }) { + return ( + + + + ) +} + +function GenericFlowInner({ queryKey, queryFn, buildGraph }: { queryKey: string[]; queryFn: () => Promise; buildGraph: (d: any) => { nodes: Node[]; edges: Edge[] } }) { + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const flowKey = queryKey.join('-') + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey) + const saveTimer = useRef | undefined>(undefined) + + const { data, isLoading, refetch } = useQuery({ queryKey, queryFn, refetchInterval: queryKey.includes('sessions') ? 10_000 : undefined }) + + useEffect(() => { + if (!data) return + const { nodes: newNodes, edges: newEdges } = buildGraph(data) + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + if (hasSaved) { + setNodes(newNodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position }))) + setEdges(newEdges) + const vp = loadViewport() + if (vp) setTimeout(() => setViewport(vp), 50) + else setTimeout(() => fitView({ padding: 0.15 }), 50) + } else { + applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln); setEdges(le) + setTimeout(() => fitView({ padding: 0.15 }), 100) + }) + } + }, [data]) + + const onNodeDragStop = useCallback((_: any, __: any, all: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(all), 500) + }, [savePositions]) + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + if (isLoading) return
+ return ( + <> + + { refetch() }}> + + + + + + ) +} + +function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Edge[] }; flowKey: string }) { + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey) + const saveTimer = useRef | undefined>(undefined) + + useEffect(() => { + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + if (hasSaved) { + setNodes(graph.nodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position }))) + setEdges(graph.edges) + const vp = loadViewport() + if (vp) setTimeout(() => setViewport(vp), 50) + else setTimeout(() => fitView({ padding: 0.15 }), 50) + } else { + applyElkLayout(graph.nodes, graph.edges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln); setEdges(le) + setTimeout(() => fitView({ padding: 0.15 }), 100) + }) + } + }, []) + + const onNodeDragStop = useCallback((_: any, __: any, all: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(all), 500) + }, [savePositions]) + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + return ( + + + + + + ) +} + +// ─── Settings Panel ──────────────────────────────────────────────────────────── + +interface AppConfigEntry { key: string; value: string; updatedAt: string } + +const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string; secret?: boolean }[] = [ + { + key: 'URL_API_DESA_PLUS', + label: 'URL API Desa Plus', + description: 'Base URL untuk API eksternal Desa Plus. Semua request dari frontend akan diproxy melalui server ke URL ini.', + placeholder: 'https://api.desa-plus.example.com', + }, + { + key: 'API_KEY_DESA_PLUS', + label: 'API Key Desa Plus', + description: 'API key untuk autentikasi ke API Desa Plus. Dikirim otomatis sebagai header X-API-Key pada setiap request proxy.', + placeholder: 'your-secret-api-key', + secret: true, + }, +] + +function SettingsPanel() { + const qc = useQueryClient() + const [values, setValues] = useState>({}) + const [saved, setSaved] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'config'], + queryFn: () => fetch('/api/admin/config', { credentials: 'include' }).then((r) => r.json()), + }) + + const configs: AppConfigEntry[] = data?.configs ?? [] + + useEffect(() => { + const initial: Record = {} + for (const def of CONFIG_DEFINITIONS) { + const existing = configs.find((c) => c.key === def.key) + initial[def.key] = existing?.value ?? '' + } + setValues(initial) + }, [configs]) + + const saveAllMutation = useMutation({ + mutationFn: async (vals: Record) => { + await Promise.all( + CONFIG_DEFINITIONS.map((def) => + fetch('/api/admin/config', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: def.key, value: vals[def.key] ?? '' }), + }) + ) + ) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'config'] }) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + notifications.show({ color: 'green', title: 'Tersimpan', message: 'Konfigurasi berhasil disimpan.' }) + }, + onError: () => { + notifications.show({ color: 'red', title: 'Gagal', message: 'Terjadi kesalahan saat menyimpan konfigurasi.' }) + }, + }) + + const hasUnconfigured = CONFIG_DEFINITIONS.some((def) => !configs.find((c) => c.key === def.key)) + + return ( + + Settings + Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy. + + {isLoading ?
: ( + + + {CONFIG_DEFINITIONS.map((def) => { + const existing = configs.find((c) => c.key === def.key) + return ( + + +
+ {def.label} + {def.key} +
+ {existing + ? Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')} + : Belum dikonfigurasi + } +
+ {def.description} + setValues((prev) => ({ ...prev, [def.key]: e.target.value }))} + placeholder={def.placeholder} + /> +
+ ) + })} + + {hasUnconfigured && ( + Beberapa konfigurasi belum diisi — data tidak akan ter-load sampai disimpan. + )} + + + + +
+
+ )} +
+ ) +} + +// ─── Unused imports fix ──────────────────────────────────────────────────────── +// Box, Container, Card, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, ThemeIcon, Title, Tooltip — all used above +// TbDots is used in OperatorsPanel menu +void TbFileText +void TbCode +void TbUser +void TbUserSearch diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index 5432337..ff41ee9 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -27,7 +27,8 @@ export const Route = createFileRoute('/login')({ queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), }) if (data?.user) { - throw redirect({ to: '/dashboard' }) + const dest = data.user.role === 'DEVELOPER' ? '/dev' : data.user.role === 'USER' ? '/profile' : '/dashboard' + throw redirect({ to: dest }) } } catch (e) { if (e instanceof Error) return @@ -59,7 +60,15 @@ function LoginPage() { {(login.isError || searchError) && ( } color="red" variant="light"> - {login.isError ? login.error.message : 'Google login failed, please try again.'} + {login.isError ? login.error.message : ( + { + google_denied: 'Login dengan Google dibatalkan.', + invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.', + token_failed: 'Gagal menukar token Google, silakan coba lagi.', + userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.', + account_disabled: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.', + }[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.' + )} )} @@ -89,6 +98,17 @@ function LoginPage() { > Sign in + + + + diff --git a/src/frontend/routes/logs.tsx b/src/frontend/routes/logs.tsx index d148033..d5075aa 100644 --- a/src/frontend/routes/logs.tsx +++ b/src/frontend/routes/logs.tsx @@ -1,22 +1,24 @@ import { + ActionIcon, Badge, + Center, Container, Group, - Stack, - Text, - Paper, - TextInput, - Select, - Avatar, - Box, - Divider, + Loader, Pagination, - Center, - Tooltip, + SegmentedControl, + Select, + Stack, + Table, + Text, + Title, } from '@mantine/core' -import { useState, useMemo, useEffect } from 'react' +import { DatePickerInput, type DatesRangeValue } from '@mantine/dates' +import dayjs from 'dayjs' +import 'dayjs/locale/id' +import { useMemo, useState } from 'react' import { createFileRoute } from '@tanstack/react-router' -import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb' +import { TbRefresh } from 'react-icons/tb' import { DashboardLayout } from '@/frontend/components/DashboardLayout' import useSWR from 'swr' import { API_URLS } from '../config/api' @@ -25,263 +27,144 @@ export const Route = createFileRoute('/logs')({ component: GlobalLogsPage, }) -const fetcher = (url: string) => fetch(url).then((res) => res.json()) +const fetcher = (url: string) => fetch(url, { credentials: 'include' }).then((r) => r.json()) -const typeConfig: Record = { - CREATE: { color: 'blue', icon: TbCheck }, - UPDATE: { color: 'teal', icon: TbCheck }, - DELETE: { color: 'red', icon: TbX }, - LOGIN: { color: 'green', icon: TbClock }, - LOGOUT: { color: 'orange', icon: TbClock }, -} - -const getRoleColor = (role: string) => { - const r = (role || '').toLowerCase() - if (r.includes('super')) return 'red' - if (r.includes('admin')) return 'brand-blue' - if (r.includes('developer')) return 'violet' - return 'gray' -} - -function groupLogsByDate(logs: any[]) { - const groups: Record = {} - - const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - - logs.forEach(log => { - const dateObj = new Date(log.createdAt) - let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - - if (dateStr === today) dateStr = 'TODAY' - else if (dateStr === yesterday) dateStr = 'YESTERDAY' - - if (!groups[dateStr]) groups[dateStr] = [] - - const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) - - groups[dateStr].push({ - id: log.id, - time: timeStr, - user: log.user, - type: log.type, - content: log.message, - color: log.user ? getRoleColor(log.user.role) : 'gray', - icon: typeConfig[log.type as string]?.icon - }) - }) - - // We want to keep the order as they came from the API (sorted by createdAt desc) - // but grouped by date. Object.entries might mess up the order if dates are not sequential. - // However, since the source logs are sorted, the first encounter of a date defines the group order. - const result: { date: string; logs: any[] }[] = [] - const seenDates = new Set() - - logs.forEach(log => { - const dateObj = new Date(log.createdAt) - let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase() - if (dateStr === today) dateStr = 'TODAY' - else if (dateStr === yesterday) dateStr = 'YESTERDAY' - - if (!seenDates.has(dateStr)) { - result.push({ date: dateStr, logs: groups[dateStr] }) - seenDates.add(dateStr) - } - }) - - return result +const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const +const LOG_TYPE_COLOR: Record = { + LOGIN: 'green', + LOGOUT: 'gray', + CREATE: 'blue', + UPDATE: 'yellow', + DELETE: 'red', } function GlobalLogsPage() { - const [search, setSearch] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - const [logType, setLogType] = useState('all') - const [operatorId, setOperatorId] = useState('all') + const [type, setType] = useState('all') + const [operatorId, setOperatorId] = useState('all') + const [dateRange, setDateRange] = useState([null, null]) const [page, setPage] = useState(1) - useEffect(() => { - const timer = setTimeout(() => setDebouncedSearch(search), 300) - return () => clearTimeout(timer) - }, [search]) - const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher) const operatorOptions = useMemo(() => { - if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }] + if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'Semua operator' }] return [ - { value: 'all', label: 'All Operators' }, - ...operatorsData.map((op: any) => ({ value: op.id, label: op.name })) + { value: 'all', label: 'Semua user' }, + ...operatorsData.map((op: any) => ({ value: op.id, label: op.name })), ] }, [operatorsData]) - const { data: response, isLoading } = useSWR( - API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'), - fetcher + const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined + const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined + + const { data, isLoading, mutate } = useSWR( + API_URLS.getGlobalLogs(page, '', type, operatorId, dateFrom, dateTo), + fetcher, + { refreshInterval: 10_000 }, ) - const filteredTimeline = useMemo(() => { - if (!response?.data) return [] - return groupLogsByDate(response.data) - }, [response?.data]) + const logs: any[] = data?.data ?? [] + const totalPages: number = data?.totalPages ?? 1 return ( - - {/* Header Controls */} - - } - radius="md" - w={250} - value={search} - onChange={(e) => { - setSearch(e.currentTarget.value) - setPage(1) - }} - /> - { - setOperatorId(val) - setPage(1) - }} - /> - + + + Activity Logs + mutate()}> + + + + + + setEditForm({ ...editForm, role: val || 'USER' })} + onChange={(val) => setEditForm({ ...editForm, role: val || 'ADMIN' })} />