Merge pull request #1 from bipprojectbali/amalia/29-apr-26

Amalia/29 apr 26
This commit is contained in:
Amalia
2026-04-29 14:00:13 +08:00
committed by GitHub
22 changed files with 2842 additions and 304 deletions

View File

@@ -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=

View File

@@ -10,11 +10,16 @@
"@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",
"@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",
@@ -198,8 +203,12 @@
"@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=="],
@@ -314,6 +323,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 +333,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,6 +359,10 @@
"@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=="],
"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=="],
@@ -406,6 +427,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=="],
@@ -432,6 +455,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 +469,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 +479,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=="],
@@ -484,6 +519,8 @@
"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=="],
@@ -888,6 +925,8 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"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=="],

View File

@@ -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

View File

@@ -28,11 +28,16 @@
"@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",
"@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",

View File

@@ -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");

View File

@@ -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())

View File

@@ -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<AuthResult | null> {
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<string, string>
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,491 @@ 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<string, any> = {}
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<string, number> = {}
const byAuth: Record<string, number> = {}
const byCategory: Record<string, number> = {}
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<string, number> = {}
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<string, string> = {}
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<string, number> = {}
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<string, string[]> = {}
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<string, string> = pkg.dependencies || {}
const devDeps: Record<string, string> = pkg.devDependencies || {}
const catMap: Record<string, string> = {
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<string, string> = {}
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<string, number> = {}
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<string, number> = {}
const uniqueUsers = new Set<string>()
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 } }
})
}

View File

@@ -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() {
<ColorSchemeScript defaultColorScheme="auto" />
<MantineProvider theme={theme} defaultColorScheme="auto">
<Notifications />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<ModalsProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</>
)

View File

@@ -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 (
<Center mih="100vh">
<LoadingOverlay visible />
</Center>
)
}
return (
<AppShell
header={{ height: 70 }}
@@ -179,12 +201,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<TbSettings size={16} />}
onClick={() => navigate({ to: '/dashboard' })}
>
Settings
</Menu.Item>
<Menu.Divider />
<Menu.Label>Danger Zone</Menu.Label>
<Menu.Item

View File

@@ -25,8 +25,12 @@ export const API_URLS = {
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}`,
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)}`,

View File

@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
@@ -41,7 +48,7 @@ export function useLogin() {
}),
onSuccess: (data) => {
queryClient.setQueryData(['auth', 'session'], data)
navigate({ to: '/dashboard' })
navigate({ to: getDefaultRoute(data.user.role) })
},
})
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from 'react'
import { useSession } from './useAuth'
export function usePresence() {
const { data } = useSession()
const [onlineUserIds, setOnlineUserIds] = useState<string[]>([])
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | 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 }
}

1483
src/frontend/routes/dev.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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) && (
<Alert icon={<TbAlertCircle size={16} />} 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.'
)}
</Alert>
)}
@@ -89,6 +98,17 @@ function LoginPage() {
>
Sign in
</Button>
<Divider label="or" labelPosition="center" />
<Button
variant="default"
fullWidth
leftSection={<FcGoogle size={18} />}
onClick={() => { window.location.href = '/api/auth/google' }}
>
Continue with Google
</Button>
</Stack>
</form>
</Paper>

View File

@@ -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<string, { color: string; icon?: any }> = {
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<string, any[]> = {}
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<string>()
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<string, string> = {
LOGIN: 'green',
LOGOUT: 'gray',
CREATE: 'blue',
UPDATE: 'yellow',
DELETE: 'red',
}
function GlobalLogsPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [logType, setLogType] = useState<string | null>('all')
const [operatorId, setOperatorId] = useState<string | null>('all')
const [type, setType] = useState('all')
const [operatorId, setOperatorId] = useState('all')
const [dateRange, setDateRange] = useState<DatesRangeValue>([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 (
<DashboardLayout>
<Container size="xl" py="lg">
{/* Header Controls */}
<Group mb="xl" gap="md">
<TextInput
placeholder="Search operator or message..."
leftSection={<TbSearch size={16} />}
radius="md"
w={250}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/>
<Select
placeholder="Log Type"
data={[
{ value: 'all', label: 'All Types' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
{ value: 'DELETE', label: 'Delete' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
]}
radius="md"
w={160}
value={logType}
onChange={(val) => {
setLogType(val)
setPage(1)
}}
/>
<Select
placeholder="Operator"
data={operatorOptions}
searchable
radius="md"
w={200}
value={operatorId}
onChange={(val) => {
setOperatorId(val)
setPage(1)
}}
/>
</Group>
<Stack>
<Group justify="space-between">
<Title order={3}>Activity Logs</Title>
<ActionIcon variant="subtle" color="gray" onClick={() => mutate()}>
<TbRefresh size={16} />
</ActionIcon>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="Filter user"
value={operatorId}
onChange={(v) => { setOperatorId(v ?? 'all'); setPage(1) }}
data={operatorOptions}
w={180}
clearable
/>
<DatePickerInput
type="range"
placeholder="Filter tanggal"
value={dateRange}
onChange={(v) => { setDateRange(v); setPage(1) }}
locale="id"
valueFormat="DD MMM YYYY"
clearable
w={300}
/>
<SegmentedControl
value={type}
onChange={(v) => { setType(v); setPage(1) }}
data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
/>
</Group>
{/* Timeline Content */}
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{isLoading ? (
<Center py="xl">
<Text c="dimmed">Loading logs...</Text>
</Center>
) : filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
<Center py="xl"><Loader /></Center>
) : (
<>
{filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}>
<Text
size="xs"
fw={700}
c="dimmed"
mt={groupIndex > 0 ? "xl" : 0}
mb="md"
style={{ textTransform: 'uppercase' }}
>
{group.date}
</Text>
<Stack gap={0} pl={4}>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
return (
<Group
key={log.id}
wrap="nowrap"
align="flex-start"
gap="lg"
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
>
{/* Left: Time */}
<Text
size="xs"
c="dimmed"
w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
>
{log.time}
</Text>
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
<Avatar
size={24}
radius="xl"
color={log.color}
variant="light"
src={log.user?.image}
style={{ cursor: 'help' }}
>
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
</Avatar>
</Tooltip>
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
{log.content}
</Text>
</Box>
</Group>
)
})}
</Stack>
{groupIndex < filteredTimeline.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
)}
</Box>
))}
{response?.totalPages > 1 && (
<Center mt="xl">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover fz="xs" style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: 160 }} />
<col style={{ width: 200 }} />
<col style={{ width: 100 }} />
<col />
</colgroup>
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Message</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log: any) => (
<Table.Tr key={log.id}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
{new Date(log.createdAt).toLocaleString('id-ID')}
</Table.Td>
<Table.Td>
{log.user ? (
<div>
<Text fw={500} truncate>{log.user.name}</Text>
<Text c="dimmed" truncate>{log.user.email}</Text>
</div>
) : <Text c="dimmed"></Text>}
</Table.Td>
<Table.Td>
<Badge color={LOG_TYPE_COLOR[log.type] ?? 'gray'} variant="light">
{log.type}
</Badge>
</Table.Td>
<Table.Td>
<Text>{log.message}</Text>
</Table.Td>
</Table.Tr>
))}
{logs.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Center py="xl"><Text c="dimmed">Belum ada log aktivitas</Text></Center>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{totalPages > 1 && (
<Center>
<Pagination total={totalPages} value={page} onChange={setPage} size="sm" />
</Center>
)}
</>
)}
</Paper>
</Stack>
</Container>
</DashboardLayout>
)

View File

@@ -1,4 +1,5 @@
import {
Alert,
Avatar,
Badge,
Button,
@@ -10,7 +11,7 @@ import {
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { TbLogout, TbUser } from 'react-icons/tb'
import { TbClock, TbLogout, TbUser } from 'react-icons/tb'
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/profile')({
@@ -30,6 +31,7 @@ export const Route = createFileRoute('/profile')({
})
const roleBadgeColor: Record<string, string> = {
USER: 'gray',
ADMIN: 'violet',
DEVELOPER: 'red',
}
@@ -55,9 +57,26 @@ function ProfilePage() {
</Button>
</Group>
{user?.role === 'USER' && (
<Alert
icon={<TbClock size={18} />}
title="Akun Menunggu Persetujuan"
color="yellow"
variant="light"
radius="md"
>
Akun kamu sedang menunggu persetujuan admin. Hubungi admin atau developer untuk mendapatkan akses ke fitur dashboard.
</Alert>
)}
<Paper withBorder p="xl" radius="md">
<Stack align="center" gap="md">
<Avatar color="blue" radius="xl" size={80}>
<Avatar
src={user?.image ?? undefined}
color="blue"
radius="xl"
size={80}
>
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div style={{ textAlign: 'center' }}>

View File

@@ -4,6 +4,7 @@ import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Card,
Container,
@@ -23,6 +24,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
@@ -37,7 +39,8 @@ import {
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
TbUserCheck,
TbUserPlus,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -50,23 +53,45 @@ export const Route = createFileRoute('/users')({
const fetcher = (url: string) => fetch(url).then((res) => res.json())
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'
if (role === 'DEVELOPER') return 'violet'
if (role === 'ADMIN') return 'brand-blue'
return 'gray'
}
const roles = [
{
name: 'DEVELOPER',
color: 'red',
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
color: 'violet',
description: 'Super admin dengan akses penuh ke seluruh sistem termasuk Dev Console.',
permissions: [
'Akses Dev Console (/dev)',
'Manajemen user & role',
'Kelola bug report & feedback',
'Lihat semua app & log aktivitas',
'Kelola versi & status aplikasi',
'Hapus log sistem',
],
},
{
name: 'ADMIN',
color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors']
color: 'blue',
description: 'Operator yang dapat mengelola aplikasi, bug, dan melihat log aktivitas.',
permissions: [
'Lihat & kelola semua aplikasi',
'Kelola bug report',
'Lihat log aktivitas',
'Lihat data user, desa, orders',
'Update status village & produk',
],
},
{
name: 'USER',
color: 'gray',
description: 'Akun baru yang belum disetujui. Menunggu approval dari Admin atau Developer.',
permissions: [
'Akses halaman profil',
'Lihat status persetujuan akun',
],
},
]
@@ -97,7 +122,7 @@ function UsersPage() {
name: '',
email: '',
password: '',
role: 'USER',
role: 'ADMIN',
})
const handleCreateUser = async () => {
@@ -119,7 +144,7 @@ function UsersPage() {
mutateOperators()
mutateStats()
closeCreate()
setCreateForm({ name: '', email: '', password: '', role: 'USER' })
setCreateForm({ name: '', email: '', password: '', role: 'ADMIN' })
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to create user')
@@ -207,6 +232,28 @@ function UsersPage() {
}
}
// ── Activate User ──
const handleActivateUser = async (user: any) => {
try {
const res = await fetch(`/api/operators/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ active: true }),
})
if (res.ok) {
notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to activate user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
}
}
return (
<DashboardLayout>
<Container size="xl" py="lg">
@@ -284,33 +331,62 @@ function UsersPage() {
) : (
operators.map((user: any) => (
<Table.Tr key={user.id}>
<Table.Td>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Group gap="sm">
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
<Box style={{ position: 'relative' }}>
<Avatar size="sm" radius="xl" color={user.active === false ? 'gray' : getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
{user.active === false && (
<Box
style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
background: 'var(--mantine-color-red-6)',
border: '1.5px solid var(--mantine-color-body)',
}}
/>
)}
</Box>
<Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text>
<Group gap={6}>
<Text fw={600} size="sm" c={user.active === false ? 'dimmed' : undefined}>{user.name}</Text>
{user.active === false && (
<Badge size="xs" color="red" variant="light">Inactive</Badge>
)}
</Group>
<Text size="xs" c="dimmed">{user.email}</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color={getRoleColor(user.role)}>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Badge variant="light" color={user.active === false ? 'gray' : getRoleColor(user.role)}>
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Text size="xs" fw={500} c={user.active === false ? 'dimmed' : undefined}>
{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} />
</ActionIcon>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} />
</ActionIcon>
{user.active === false ? (
<Tooltip label="Aktifkan user" withArrow>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="teal" onClick={() => handleActivateUser(user)}>
<TbUserPlus size={14} />
</ActionIcon>
</Tooltip>
) : (
<>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
<TbPencil size={14} />
</ActionIcon>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
<TbTrash size={14} />
</ActionIcon>
</>
)}
</Group>
</Table.Td>
</Table.Tr>
@@ -345,8 +421,8 @@ function UsersPage() {
</Group>
<Stack gap={4}>
<Title order={4}>{role.name.replace('_', ' ')}</Title>
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
<Title order={4}>{role.name}</Title>
<Text size="sm" c="dimmed">{role.description}</Text>
</Stack>
<Divider />
@@ -457,11 +533,12 @@ function UsersPage() {
<Select
label="Role"
data={[
{ value: 'USER', label: 'User (Pending)' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' },
]}
value={editForm.role}
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
onChange={(val) => setEditForm({ ...editForm, role: val || 'ADMIN' })}
/>
<Button
fullWidth

45
src/lib/applog.ts Normal file
View File

@@ -0,0 +1,45 @@
import { redis } from './redis'
export type LogLevel = 'info' | 'warn' | 'error'
export interface AppLogEntry {
id: number
level: LogLevel
message: string
detail?: string
timestamp: string
}
const REDIS_KEY = 'app:logs'
const MAX_ENTRIES = 500
const ID_KEY = 'app:logs:next_id'
export async function appLog(level: LogLevel, message: string, detail?: string) {
if (!redis) return
const id = await redis.incr(ID_KEY)
const entry: AppLogEntry = { id, level, message, detail, timestamp: new Date().toISOString() }
await redis.lpush(REDIS_KEY, JSON.stringify(entry))
await redis.ltrim(REDIS_KEY, 0, MAX_ENTRIES - 1)
}
export async function getAppLogs(options?: {
level?: LogLevel
limit?: number
afterId?: number
}): Promise<AppLogEntry[]> {
if (!redis) return []
const limit = options?.limit ?? 100
const fetchCount = options?.level || options?.afterId ? MAX_ENTRIES : limit
const raw = await redis.lrange(REDIS_KEY, 0, fetchCount - 1)
let logs: AppLogEntry[] = raw.map((s: string) => JSON.parse(s))
if (options?.afterId) logs = logs.filter((l) => l.id > options.afterId!)
if (options?.level) logs = logs.filter((l) => l.level === options.level)
logs.reverse()
return logs.slice(-limit)
}
export async function clearAppLogs() {
if (!redis) return
await redis.del(REDIS_KEY)
await redis.del(ID_KEY)
}

View File

@@ -12,6 +12,7 @@ export const env = {
PORT: parseInt(optional('PORT', '3000'), 10),
NODE_ENV: optional('NODE_ENV', 'development'),
REACT_EDITOR: optional('REACT_EDITOR', 'code'),
BASE_URL: optional('BUN_PUBLIC_BASE_URL', 'http://localhost:3000'),
DATABASE_URL: required('DATABASE_URL'),
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
@@ -24,4 +25,5 @@ export const env = {
MINIO_SECRET_KEY: required('MINIO_SECRET_KEY'),
MINIO_BUCKET: required('MINIO_BUCKET'),
MINIO_UPLOAD_DIR: optional('MINIO_UPLOAD_DIR', 'bug-reports'),
REDIS_URL: optional('REDIS_URL', ''),
} as const

44
src/lib/presence.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { ServerWebSocket } from 'bun'
const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>()
const adminSubs = new Set<ServerWebSocket<{ userId: string }>>()
export function getOnlineUserIds(): string[] {
return Array.from(connections.keys())
}
function broadcast() {
const online = getOnlineUserIds()
const msg = JSON.stringify({ type: 'presence', online })
for (const ws of adminSubs) ws.send(msg)
}
export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: string, isAdmin: boolean) {
let set = connections.get(userId)
if (!set) {
set = new Set()
connections.set(userId, set)
}
set.add(ws)
if (isAdmin) {
adminSubs.add(ws)
ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() }))
}
broadcast()
}
export function broadcastToAdmins(message: object) {
const msg = JSON.stringify(message)
for (const ws of adminSubs) ws.send(msg)
}
export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
const userId = ws.data.userId
const set = connections.get(userId)
if (set) {
set.delete(ws)
if (set.size === 0) connections.delete(userId)
}
adminSubs.delete(ws)
broadcast()
}

3
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,3 @@
import { env } from './env'
export const redis = env.REDIS_URL ? new Bun.RedisClient(env.REDIS_URL) : null

104
src/lib/schema-parser.ts Normal file
View File

@@ -0,0 +1,104 @@
export interface SchemaField {
name: string
type: string
isId: boolean
isUnique: boolean
isOptional: boolean
isList: boolean
isRelation: boolean
default?: string
}
export interface SchemaRelation {
from: string
fromField: string
to: string
toField: string
onDelete?: string
}
export interface SchemaModel {
name: string
tableName: string
fields: SchemaField[]
}
export interface SchemaEnum {
name: string
values: string[]
}
export interface ParsedSchema {
models: SchemaModel[]
enums: SchemaEnum[]
relations: SchemaRelation[]
}
export function parseSchema(raw: string): ParsedSchema {
const models: SchemaModel[] = []
const enums: SchemaEnum[] = []
const relations: SchemaRelation[] = []
const blocks = raw.match(/(model|enum)\s+(\w+)\s*\{([^}]*)}/gs) ?? []
for (const block of blocks) {
const match = block.match(/(model|enum)\s+(\w+)\s*\{([^}]*)}/s)
if (!match) continue
const [, type, name, body] = match
const lines = body
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('//'))
if (type === 'enum') {
enums.push({ name, values: lines })
continue
}
let tableName = name
const fields: SchemaField[] = []
for (const line of lines) {
const mapMatch = line.match(/@@map\("(\w+)"\)/)
if (mapMatch) { tableName = mapMatch[1]; continue }
if (line.startsWith('@@')) continue
const fieldMatch = line.match(/^(\w+)\s+(\w+)(\?)?(\[\])?\s*(.*)$/)
if (!fieldMatch) continue
const [, fName, fType, optional, list, attrs] = fieldMatch
const isId = attrs.includes('@id')
const isUnique = attrs.includes('@unique')
const isRelation = attrs.includes('@relation')
const defaultMatch = attrs.match(/@default\(([^)]+)\)/)
const isModelRef =
/^[A-Z]/.test(fType) &&
!enums.some((e) => e.name === fType) &&
!['String', 'Int', 'Float', 'Boolean', 'DateTime', 'BigInt', 'Decimal', 'Bytes', 'Json'].includes(fType)
if (isRelation) {
const relMatch = attrs.match(
/@relation\(fields:\s*\[(\w+)],\s*references:\s*\[(\w+)](?:,\s*onDelete:\s*(\w+))?\)/,
)
if (relMatch) {
relations.push({ from: name, fromField: relMatch[1], to: fType, toField: relMatch[2], onDelete: relMatch[3] })
}
}
fields.push({
name: fName,
type: fType + (list ? '[]' : ''),
isId, isUnique,
isOptional: !!optional,
isList: !!list,
isRelation: isModelRef,
default: defaultMatch?.[1],
})
}
models.push({ name, tableName, fields })
}
return { models, enums, relations }
}