This commit is contained in:
+11
-1
@@ -30,4 +30,14 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Package Release
|
||||
run: tar -czf ../jumpa.tar.gz --exclude=node_modules --exclude=.git . && mv ../jumpa.tar.gz .
|
||||
run: |
|
||||
tar -czf ../jumpa.tar.gz \
|
||||
--exclude=node_modules \
|
||||
--exclude=.git \
|
||||
--exclude=prisma/dev.db \
|
||||
--exclude=prisma/dev.db-journal \
|
||||
--exclude='prisma/*.db' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.local' \
|
||||
.
|
||||
mv ../jumpa.tar.gz .
|
||||
|
||||
@@ -39,3 +39,12 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# deploy artifacts
|
||||
*.tar.gz
|
||||
|
||||
+5
-1
@@ -1,4 +1,6 @@
|
||||
# [TSM.ID].[11031972] — Phantom V5.2 Config (jumpa-app / jumpa-web repo)
|
||||
# [TSM.ID].[11031972] — Phantom V5.2 Config
|
||||
# Gitea repo: jumpa.id/jumpa-app
|
||||
# Flow: git push → Gitea webhook → Phantom V5.2 → blue-green deploy A1,A2
|
||||
pool: APP
|
||||
nodes:
|
||||
- A1
|
||||
@@ -10,6 +12,8 @@ deploy_path: "/opt/jumpa/"
|
||||
db_migrate: "122.248.34.132"
|
||||
post_deploy:
|
||||
- "cd /opt/jumpa/live && npm ci --production"
|
||||
- "cd /opt/jumpa/live && npx prisma generate"
|
||||
- "cd /opt/jumpa/live && npx prisma db push --accept-data-loss"
|
||||
- "pm2 restart ecosystem.config.js || pm2 start ecosystem.config.js"
|
||||
- "sleep 3"
|
||||
- "curl -sf http://localhost:3005/health || exit 1"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# [TSM.ID].[11031972] — Nginx for jumpa.id (A1/A2)
|
||||
|
||||
# Landing page marketing
|
||||
server {
|
||||
listen 80;
|
||||
server_name jumpa.id;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:3005/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
|
||||
# Web App
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.jumpa.id;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# WebSocket support for video conference
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
location /_next/static {
|
||||
proxy_pass http://127.0.0.1:3005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// [TSM.ID].[11031972] — PM2 Configuration for jumpa-app
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'jumpa-app',
|
||||
script: 'node_modules/.bin/next',
|
||||
args: 'start -p 3005',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3005,
|
||||
},
|
||||
max_memory_restart: '512M',
|
||||
error_file: '/var/log/jumpa/error.log',
|
||||
out_file: '/var/log/jumpa/out.log',
|
||||
merge_logs: true,
|
||||
time: true,
|
||||
}]
|
||||
};
|
||||
+11
-1
@@ -1,7 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Next.js Configuration
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/health',
|
||||
destination: '/api/health',
|
||||
},
|
||||
];
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+445
-2
@@ -8,17 +8,22 @@
|
||||
"name": "jumpa-app",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.3",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"jose": "^6.0.11",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -1246,6 +1251,91 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||
"integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prisma": "*",
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"prisma": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"c12": "3.1.0",
|
||||
"deepmerge-ts": "7.1.5",
|
||||
"effect": "3.21.0",
|
||||
"empathic": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"@prisma/fetch-engine": "6.19.3",
|
||||
"@prisma/get-platform": "6.19.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
"@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"@prisma/get-platform": "6.19.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1253,6 +1343,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1544,6 +1641,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -2508,6 +2612,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
@@ -2566,6 +2679,35 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"magicast": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -2663,6 +2805,32 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2696,6 +2864,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2811,6 +2996,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
@@ -2847,6 +3042,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2870,6 +3079,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2885,6 +3107,17 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"fast-check": "^3.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.364",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
||||
@@ -2899,6 +3132,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/empathic": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.22.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
|
||||
@@ -3519,6 +3762,36 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^6.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3791,6 +4064,24 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"consola": "^3.4.0",
|
||||
"defu": "^6.1.4",
|
||||
"node-fetch-native": "^1.6.6",
|
||||
"nypm": "^0.6.0",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -4468,12 +4759,21 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -5123,6 +5423,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||
@@ -5133,6 +5440,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.6.6",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
|
||||
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.2.2",
|
||||
"pathe": "^2.0.3",
|
||||
"tinyexec": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"nypm": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm/node_modules/citty": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -5256,6 +5588,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -5364,6 +5703,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -5383,6 +5736,18 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.4",
|
||||
"exsolve": "^1.0.8",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -5432,6 +5797,32 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.19.3",
|
||||
"@prisma/engines": "6.19.3"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -5454,6 +5845,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5475,6 +5883,17 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.4",
|
||||
"destr": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@@ -5503,6 +5922,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6139,6 +6572,16 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -6340,7 +6783,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -9,17 +9,22 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.3",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"jose": "^6.0.11",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Database Schema (jumpadb)
|
||||
// Target: JMP-DB1 (122.248.34.132)
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
xcuClientId String @map("xcu_client_id")
|
||||
plan String @default("basic")
|
||||
logoUrl String? @map("logo_url")
|
||||
primaryColor String @default("#10b981") @map("primary_color")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
users User[]
|
||||
byokKeys ByokKey[]
|
||||
messages Message[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
email String
|
||||
passwordHash String @map("password_hash")
|
||||
displayName String @map("display_name")
|
||||
role String @default("member")
|
||||
avatarUrl String? @map("avatar_url")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
sessions Session[]
|
||||
messages Message[] @relation("SentMessages")
|
||||
byokKeys ByokKey[] @relation("CreatedByokKeys")
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
tokenHash String @map("token_hash")
|
||||
deviceType String? @map("device_type")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tokenHash])
|
||||
@@index([userId])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model ByokKey {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
publicKeyPem String @map("public_key_pem")
|
||||
publicKeyHash String @map("public_key_hash")
|
||||
algorithm String @default("RSA-4096")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdBy String? @map("created_by")
|
||||
createdByUser User? @relation("CreatedByokKeys", fields: [createdBy], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
rotatedAt DateTime? @map("rotated_at")
|
||||
|
||||
@@map("byok_keys")
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
roomCode String @map("room_code")
|
||||
senderId String @map("sender_id")
|
||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
contentNonce Bytes @map("content_nonce")
|
||||
messageType String @default("text") @map("message_type")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([roomCode, createdAt(sort: Desc)])
|
||||
@@map("messages")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
userId String? @map("user_id")
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
action String
|
||||
resourceType String? @map("resource_type")
|
||||
resourceId String? @map("resource_id")
|
||||
ipAddress String? @map("ip_address")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tenantId, createdAt(sort: Desc)])
|
||||
@@map("audit_log")
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -0,0 +1,80 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Default Tenant Seeder
|
||||
// Jalankan SEKALI di VPS setelah prisma db push
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('[JUMPA] Seeding default tenant...');
|
||||
|
||||
// Buat tenant PT. JUMPA (sesuai arsitektur BAB I Pasal 2)
|
||||
const tenant = await prisma.tenant.upsert({
|
||||
where: { slug: 'jumpa' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'PT. JUMPA',
|
||||
slug: 'jumpa',
|
||||
xcuClientId: 'xcu_client_jumpa_001', // ID dari xcudb.clients
|
||||
plan: 'enterprise',
|
||||
primaryColor: '#10b981',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Tenant created: ${tenant.name} (${tenant.slug})`);
|
||||
console.log(` XCU Client ID: ${tenant.xcuClientId}`);
|
||||
console.log(` Plan: ${tenant.plan}`);
|
||||
|
||||
// Buat admin user untuk tenant
|
||||
// Password: admin123 (HARUS diganti di production!)
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const passwordHash = await bcrypt.hash('admin123', 12);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: {
|
||||
tenantId_email: {
|
||||
tenantId: tenant.id,
|
||||
email: 'admin@jumpa.id',
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
tenantId: tenant.id,
|
||||
email: 'admin@jumpa.id',
|
||||
passwordHash,
|
||||
displayName: 'Admin JUMPA',
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Admin user created: ${admin.email} (role: ${admin.role})`);
|
||||
console.log('');
|
||||
console.log('⚠️ DEFAULT PASSWORD: admin123');
|
||||
console.log('⚠️ SEGERA GANTI PASSWORD DI PRODUCTION!');
|
||||
console.log('');
|
||||
|
||||
// Log audit
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
userId: admin.id,
|
||||
action: 'SYSTEM_SEED',
|
||||
resourceType: 'tenant',
|
||||
resourceId: tenant.id,
|
||||
metadata: { note: 'Initial production seed by setup-db.sh' },
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Audit log recorded');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('[JUMPA] Seed error:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# [TSM.ID].[11031972] — JUMPA Database Setup untuk Production VPS
|
||||
# Jalankan di A1 SETELAH deploy pertama kali
|
||||
# Usage: bash scripts/setup-db.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "╔════════════════════════════════════════════════════╗"
|
||||
echo "║ [TSM.ID].[11031972] — JUMPA Database Setup ║"
|
||||
echo "║ Target: JMP-DB1 (jumpadb) PostgreSQL ║"
|
||||
echo "╚════════════════════════════════════════════════════╝"
|
||||
|
||||
cd /opt/jumpa/live
|
||||
|
||||
# Step 1: Generate Prisma Client
|
||||
echo ""
|
||||
echo "▶ [1/3] Generating Prisma Client..."
|
||||
npx prisma generate
|
||||
echo "✅ Prisma Client generated"
|
||||
|
||||
# Step 2: Push schema ke PostgreSQL (buat semua tabel)
|
||||
echo ""
|
||||
echo "▶ [2/3] Pushing schema to PostgreSQL (jumpadb)..."
|
||||
echo " This will CREATE all tables: tenants, users, sessions, byok_keys, messages, audit_log"
|
||||
npx prisma db push --accept-data-loss
|
||||
echo "✅ Schema pushed — all 6 tables created"
|
||||
|
||||
# Step 3: Seed default tenant (PT. JUMPA)
|
||||
echo ""
|
||||
echo "▶ [3/3] Seeding default tenant..."
|
||||
npx tsx scripts/seed-tenant.ts
|
||||
echo "✅ Default tenant seeded"
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ DATABASE SETUP COMPLETE ║"
|
||||
echo "║ ║"
|
||||
echo "║ Verify: curl http://localhost:3005/health ║"
|
||||
echo "║ Tables: npx prisma studio ║"
|
||||
echo "║ ║"
|
||||
echo "║ Tables created: ║"
|
||||
echo "║ · tenants (multi-tenant registry) ║"
|
||||
echo "║ · users (user accounts per tenant) ║"
|
||||
echo "║ · sessions (JWT session tracking) ║"
|
||||
echo "║ · byok_keys (BYOK public keys) ║"
|
||||
echo "║ · messages (encrypted chat messages) ║"
|
||||
echo "║ · audit_log (security audit trail) ║"
|
||||
echo "╚════════════════════════════════════════════════════╝"
|
||||
@@ -0,0 +1,139 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Login Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifyPassword, createToken } from '@/lib/auth';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password, tenantSlug } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password || !tenantSlug) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Semua field wajib diisi: email, password, tenantSlug' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find tenant
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { slug: tenantSlug },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kredensial tidak valid' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find user in tenant
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
tenantId_email: {
|
||||
tenantId: tenant.id,
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kredensial tidak valid' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kredensial tidak valid' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
const token = await createToken(user.id, tenant.id);
|
||||
|
||||
// Hash token for session storage
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
|
||||
// Create session in DB
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const ipAddress = forwarded?.split(',')[0]?.trim() || undefined;
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
deviceType: userAgent?.includes('Mobile') ? 'mobile' : 'desktop',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
userId: user.id,
|
||||
action: 'user.login',
|
||||
resourceType: 'session',
|
||||
ipAddress,
|
||||
metadata: { deviceType: userAgent?.includes('Mobile') ? 'mobile' : 'desktop' },
|
||||
},
|
||||
});
|
||||
|
||||
// Set cookie and return
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
|
||||
response.cookies.set('jumpa_session', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 24 * 60 * 60, // 24 hours in seconds
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[LOGIN ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Terjadi kesalahan server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Logout Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get('jumpa_session');
|
||||
|
||||
if (sessionCookie?.value) {
|
||||
// Verify and decode the token
|
||||
const payload = await verifyToken(sessionCookie.value);
|
||||
|
||||
if (payload) {
|
||||
// Hash token to find matching session
|
||||
const tokenHash = createHash('sha256').update(sessionCookie.value).digest('hex');
|
||||
|
||||
// Delete session from DB
|
||||
await prisma.session.deleteMany({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: payload.tenantId,
|
||||
userId: payload.userId,
|
||||
action: 'user.logout',
|
||||
resourceType: 'session',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cookie regardless
|
||||
const response = NextResponse.json(
|
||||
{ success: true, message: 'Logout berhasil' },
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
|
||||
response.cookies.set('jumpa_session', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[LOGOUT ERROR]', error);
|
||||
// Still clear cookie on error
|
||||
const response = NextResponse.json(
|
||||
{ success: true, message: 'Logout berhasil' },
|
||||
{ status: 200 }
|
||||
);
|
||||
response.cookies.set('jumpa_session', '', {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Register Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { hashPassword } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password, displayName, tenantSlug } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !password || !displayName || !tenantSlug) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Semua field wajib diisi: email, password, displayName, tenantSlug' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Format email tidak valid' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password minimal 8 karakter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find tenant by slug
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { slug: tenantSlug },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tenant tidak ditemukan' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists in this tenant
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
tenantId_email: {
|
||||
tenantId: tenant.id,
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email sudah terdaftar di tenant ini' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash password and create user
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: email.toLowerCase(),
|
||||
passwordHash,
|
||||
displayName,
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
userId: user.id,
|
||||
action: 'user.register',
|
||||
resourceType: 'user',
|
||||
resourceId: user.id,
|
||||
metadata: { email: user.email },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Registrasi berhasil',
|
||||
user,
|
||||
},
|
||||
{
|
||||
status: 201,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[REGISTER ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Terjadi kesalahan server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Health Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
app: 'jumpa-app',
|
||||
version: '0.1.0',
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
watermark: '[TSM.ID].[11031972]'
|
||||
}, {
|
||||
status: 200,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Join Room Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import { xcuClient } from '@/lib/xcu-client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
try {
|
||||
const { code } = await params;
|
||||
|
||||
// Verify JWT
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get('jumpa_session');
|
||||
|
||||
if (!sessionCookie?.value) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyToken(sessionCookie.value);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Token tidak valid' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: { id: true, displayName: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User tidak ditemukan' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Join room via XCU Engine
|
||||
const result = await xcuClient.joinRoom(code, {
|
||||
externalUserId: user.id,
|
||||
displayName: user.displayName,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
participant: result.participant,
|
||||
sfuConfig: result.sfuConfig,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[JOIN ROOM ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Gagal bergabung ke rapat. Silakan coba lagi.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Leave Room Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import { xcuClient } from '@/lib/xcu-client';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> }
|
||||
) {
|
||||
try {
|
||||
const { code } = await params;
|
||||
|
||||
// Verify JWT
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get('jumpa_session');
|
||||
|
||||
if (!sessionCookie?.value) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyToken(sessionCookie.value);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Token tidak valid' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const participantId = body.participantId || payload.userId;
|
||||
|
||||
// Leave room via XCU Engine
|
||||
await xcuClient.leaveRoom(code, participantId);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Berhasil keluar dari rapat',
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[LEAVE ROOM ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Gagal keluar dari rapat' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Create Room Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import { xcuClient } from '@/lib/xcu-client';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Verify JWT
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get('jumpa_session');
|
||||
|
||||
if (!sessionCookie?.value) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyToken(sessionCookie.value);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Token tidak valid' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const maxParticipants = body.maxParticipants || 50;
|
||||
|
||||
// Create room via XCU Engine
|
||||
const room = await xcuClient.createRoom({
|
||||
maxParticipants,
|
||||
displayName: body.displayName || 'JUMPA Meeting',
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
roomCode: room.roomCode,
|
||||
room,
|
||||
},
|
||||
{
|
||||
status: 201,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[CREATE ROOM ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Gagal membuat rapat. Silakan coba lagi.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID User Profile Endpoint
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Verify JWT
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get('jumpa_session');
|
||||
|
||||
if (!sessionCookie?.value) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyToken(sessionCookie.value);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Token tidak valid' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user with tenant info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
avatarUrl: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
plan: true,
|
||||
primaryColor: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return NextResponse.json({ error: 'User tidak ditemukan' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
user,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'X-Provenance-Watermark': '[TSM.ID].[11031972]' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[PROFILE ERROR]', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Terjadi kesalahan server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Auth Page (Login/Register)
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
function AuthForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Form fields
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [tenantSlug, setTenantSlug] = useState('jumpa');
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, tenantSlug }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Login gagal');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(redirect);
|
||||
} catch {
|
||||
setError('Tidak dapat terhubung ke server');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, displayName, tenantSlug }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Registrasi gagal');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess('Registrasi berhasil! Silakan login.');
|
||||
setActiveTab('login');
|
||||
setPassword('');
|
||||
} catch {
|
||||
setError('Tidak dapat terhubung ke server');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-container">
|
||||
{/* Logo */}
|
||||
<div className="auth-logo">
|
||||
<span className="auth-logo-icon">🔒</span>
|
||||
<h1 className="auth-logo-text">JUMPA.ID</h1>
|
||||
<p className="auth-logo-sub">Video Conference Terenkripsi</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="auth-tabs">
|
||||
<button
|
||||
className={`auth-tab ${activeTab === 'login' ? 'auth-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab('login'); setError(''); setSuccess(''); }}
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
<button
|
||||
className={`auth-tab ${activeTab === 'register' ? 'auth-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab('register'); setError(''); setSuccess(''); }}
|
||||
>
|
||||
Daftar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && <div className="auth-message auth-error">{error}</div>}
|
||||
{success && <div className="auth-message auth-success">{success}</div>}
|
||||
|
||||
{/* Login Form */}
|
||||
{activeTab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="auth-form">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="login-email">Email</label>
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="nama@perusahaan.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="login-tenant">Organisasi</label>
|
||||
<input
|
||||
id="login-tenant"
|
||||
type="text"
|
||||
value={tenantSlug}
|
||||
onChange={(e) => setTenantSlug(e.target.value)}
|
||||
placeholder="slug-organisasi"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="auth-submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Memproses...' : 'Masuk'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Register Form */}
|
||||
{activeTab === 'register' && (
|
||||
<form onSubmit={handleRegister} className="auth-form">
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-name">Nama Lengkap</label>
|
||||
<input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-email">Email</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="nama@perusahaan.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-password">Password</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Minimal 8 karakter"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label htmlFor="reg-tenant">Organisasi</label>
|
||||
<input
|
||||
id="reg-tenant"
|
||||
type="text"
|
||||
value={tenantSlug}
|
||||
onChange={(e) => setTenantSlug(e.target.value)}
|
||||
placeholder="slug-organisasi"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="auth-submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Memproses...' : 'Daftar Sekarang'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Watermark */}
|
||||
<p className="auth-watermark">[TSM.ID].[11031972]</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="auth-page">
|
||||
<div className="auth-container">
|
||||
<div className="auth-logo">
|
||||
<span className="auth-logo-icon">🔒</span>
|
||||
<h1 className="auth-logo-text">JUMPA.ID</h1>
|
||||
<p className="auth-logo-sub">Memuat...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<AuthForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Dashboard Page
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [roomCode, setRoomCode] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
setIsCreating(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/rooms/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ maxParticipants: 50 }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Gagal membuat rapat');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/room/${data.roomCode}`);
|
||||
} catch {
|
||||
setError('Tidak dapat terhubung ke server');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinRoom = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!roomCode.trim()) return;
|
||||
|
||||
setIsJoining(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${roomCode}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Gagal bergabung ke rapat');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/room/${roomCode}`);
|
||||
} catch {
|
||||
setError('Tidak dapat terhubung ke server');
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/auth');
|
||||
};
|
||||
|
||||
// Placeholder data for recent meetings
|
||||
const recentMeetings = [
|
||||
{ id: '1', name: 'Standup Pagi', code: 'JMP-A1B2C3', date: 'Hari ini, 09:00', participants: 5, duration: '15 menit' },
|
||||
{ id: '2', name: 'Review Sprint 12', code: 'JMP-D4E5F6', date: 'Kemarin, 14:00', participants: 8, duration: '45 menit' },
|
||||
{ id: '3', name: 'Demo Client', code: 'JMP-G7H8I9', date: '29 Mei, 10:30', participants: 12, duration: '60 menit' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
{/* Welcome */}
|
||||
<div className="dashboard-welcome">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div>
|
||||
<h1>Selamat Datang 👋</h1>
|
||||
<p>Kelola rapat dan komunikasi terenkripsi Anda.</p>
|
||||
</div>
|
||||
<button onClick={handleLogout} className="btn btn-secondary" style={{ fontSize: '13px', padding: '8px 16px' }}>
|
||||
Keluar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="auth-message auth-error" style={{ marginBottom: '24px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📹</div>
|
||||
<p className="stat-label">Rapat Aktif</p>
|
||||
<p className="stat-value">0</p>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📊</div>
|
||||
<p className="stat-label">Total Rapat</p>
|
||||
<p className="stat-value">24</p>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⏱️</div>
|
||||
<p className="stat-label">Menit Digunakan</p>
|
||||
<p className="stat-value">1,280</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="dashboard-actions">
|
||||
<div className="action-card">
|
||||
<h3>🚀 Buat Rapat Baru</h3>
|
||||
<p>Buat ruang rapat terenkripsi end-to-end dan undang peserta.</p>
|
||||
<button
|
||||
onClick={handleCreateRoom}
|
||||
className="btn btn-primary"
|
||||
disabled={isCreating}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isCreating ? 'Membuat...' : 'Buat Rapat Baru'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="action-card">
|
||||
<h3>🔗 Gabung Rapat</h3>
|
||||
<p>Masukkan kode rapat untuk bergabung ke rapat yang sudah ada.</p>
|
||||
<form onSubmit={handleJoinRoom} className="join-form">
|
||||
<input
|
||||
type="text"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value)}
|
||||
placeholder="Masukkan kode rapat"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isJoining}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isJoining ? '...' : 'Gabung'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Meetings */}
|
||||
<div className="meetings-section">
|
||||
<h2>Rapat Terakhir</h2>
|
||||
{recentMeetings.map((meeting) => (
|
||||
<div key={meeting.id} className="meeting-item">
|
||||
<div className="meeting-info">
|
||||
<h4>{meeting.name}</h4>
|
||||
<p>
|
||||
{meeting.code} • {meeting.date} • {meeting.participants} peserta • {meeting.duration}
|
||||
</p>
|
||||
</div>
|
||||
<div className="meeting-badge">
|
||||
🔒 Terenkripsi
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1045
-14
File diff suppressed because it is too large
Load Diff
+66
-20
@@ -1,20 +1,27 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Root Layout
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "JUMPA.ID — Video Conference Platform Masa Depan",
|
||||
description:
|
||||
"Platform video conference terenkripsi end-to-end dengan BYOK Matrix, post-quantum encryption, dan dukungan 9 platform. Powered by XCU Engine.",
|
||||
keywords: [
|
||||
"video conference",
|
||||
"encrypted",
|
||||
"BYOK",
|
||||
"Indonesia",
|
||||
"jumpa",
|
||||
"end-to-end encryption",
|
||||
],
|
||||
authors: [{ name: "TSM.ID" }],
|
||||
openGraph: {
|
||||
title: "JUMPA.ID — Video Conference Platform Masa Depan",
|
||||
description:
|
||||
"Platform video conference terenkripsi end-to-end dengan BYOK Matrix dan post-quantum encryption.",
|
||||
siteName: "JUMPA.ID",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,13 +30,52 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="id">
|
||||
<body>
|
||||
{/* Navigation */}
|
||||
<nav className="nav">
|
||||
<div className="nav-inner">
|
||||
<a href="/" className="nav-logo">
|
||||
JUMPA<span className="nav-logo-dot">.ID</span>
|
||||
</a>
|
||||
<ul className="nav-links">
|
||||
<li>
|
||||
<a href="/#features" className="nav-link">
|
||||
Fitur
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#comparison" className="nav-link">
|
||||
Perbandingan
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#pricing" className="nav-link">
|
||||
Harga
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="nav-actions">
|
||||
<a href="/auth" className="btn btn-primary" style={{ padding: "8px 20px", fontSize: "13px" }}>
|
||||
Login / Daftar
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main>{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p className="footer-text">
|
||||
© {new Date().getFullYear()} JUMPA.ID — PT. JUMPA. All rights reserved.
|
||||
</p>
|
||||
<p className="footer-watermark">[TSM.ID].[11031972]</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
// [TSM.ID].[11031972]
|
||||
|
||||
|
||||
+186
-57
@@ -1,65 +1,194 @@
|
||||
import Image from "next/image";
|
||||
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Landing Page Marketing
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<>
|
||||
{/* ─── Hero Section ─── */}
|
||||
<section className="hero">
|
||||
<div className="hero-glow" />
|
||||
<div className="hero-content animate-fade-in">
|
||||
<div className="hero-badge">
|
||||
<span>🔒</span>
|
||||
<span>Post-Quantum Encrypted • XChaCha20 + Kyber1024</span>
|
||||
</div>
|
||||
<h1>
|
||||
<span className="text-gradient">Video Conference</span>
|
||||
<br />
|
||||
Masa Depan
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<p className="hero-subtitle">
|
||||
Enkripsi end-to-end tingkat militer, BYOK (Bring Your Own Key) Matrix,
|
||||
dukungan 9 platform, dan zero-trust architecture.
|
||||
Ditenagai oleh 145 modul Rust dari XCU Engine.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<a href="/auth" className="btn btn-primary">
|
||||
🚀 Mulai Gratis
|
||||
</a>
|
||||
<a href="#features" className="btn btn-secondary">
|
||||
Pelajari Lebih →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* ─── Features Section ─── */}
|
||||
<section id="features" className="features">
|
||||
<div className="container">
|
||||
<div className="section-header">
|
||||
<h2>
|
||||
Kenapa <span className="text-gradient">JUMPA.ID</span>?
|
||||
</h2>
|
||||
<p>
|
||||
Dibangun untuk keamanan tanpa kompromi dan performa yang tak tertandingi.
|
||||
</p>
|
||||
</div>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card animate-slide-up">
|
||||
<span className="feature-icon">🔐</span>
|
||||
<h3>Enkripsi End-to-End</h3>
|
||||
<p>
|
||||
XChaCha20-Poly1305 untuk simetris, Kyber1024 untuk post-quantum
|
||||
key exchange. Bahkan server kami tidak bisa membaca data Anda.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card animate-slide-up" style={{ animationDelay: "0.1s" }}>
|
||||
<span className="feature-icon">🔑</span>
|
||||
<h3>BYOK Matrix</h3>
|
||||
<p>
|
||||
Bring Your Own Key — gunakan kunci enkripsi sendiri. Rotasi otomatis,
|
||||
audit trail lengkap, dan kontrol penuh di tangan Anda.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card animate-slide-up" style={{ animationDelay: "0.2s" }}>
|
||||
<span className="feature-icon">📱</span>
|
||||
<h3>9 Platform</h3>
|
||||
<p>
|
||||
Android, iOS, Windows, macOS, Linux, Browser, CLI, Embedded,
|
||||
dan Offline BLE. Satu platform untuk semua perangkat.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card animate-slide-up" style={{ animationDelay: "0.3s" }}>
|
||||
<span className="feature-icon">⚡</span>
|
||||
<h3>Zero Downtime</h3>
|
||||
<p>
|
||||
SLA 99.99% dengan blue-green deployment via Phantom V5.2.
|
||||
Multi-node failover, auto-scaling, dan rolling updates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card animate-slide-up" style={{ animationDelay: "0.4s" }}>
|
||||
<span className="feature-icon">🌐</span>
|
||||
<h3>Anti-Sensor</h3>
|
||||
<p>
|
||||
DPI evasion, multi-path routing, domain fronting, dan
|
||||
steganographic tunneling. Komunikasi Anda tidak bisa diblokir.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card animate-slide-up" style={{ animationDelay: "0.5s" }}>
|
||||
<span className="feature-icon">🎯</span>
|
||||
<h3>145 Modul Rust</h3>
|
||||
<p>
|
||||
Powered by XCU Engine — 145 modul Rust yang di-compile ke native code.
|
||||
Memory safety, zero-cost abstractions, dan performa C/C++.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── Comparison Section ─── */}
|
||||
<section id="comparison" className="comparison">
|
||||
<div className="container">
|
||||
<div className="section-header">
|
||||
<h2>
|
||||
<span className="text-gradient">JUMPA.ID</span> vs Kompetitor
|
||||
</h2>
|
||||
<p>
|
||||
Bandingkan fitur keamanan dan privasi yang tidak ditawarkan platform lain.
|
||||
</p>
|
||||
</div>
|
||||
<table className="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fitur</th>
|
||||
<th>JUMPA.ID</th>
|
||||
<th>Zoom</th>
|
||||
<th>Teams</th>
|
||||
<th>Google Meet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>BYOK (Bring Your Own Key)</td>
|
||||
<td className="comparison-highlight">✅ Full BYOK</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Post-Quantum Encryption</td>
|
||||
<td className="comparison-highlight">✅ Kyber1024</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sovereign / Self-Hosted</td>
|
||||
<td className="comparison-highlight">✅ On-Prem</td>
|
||||
<td>❌</td>
|
||||
<td>⚠️ Partial</td>
|
||||
<td>❌</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Anti-DPI / Anti-Sensor</td>
|
||||
<td className="comparison-highlight">✅ Multi-Path</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Offline BLE Mesh</td>
|
||||
<td className="comparison-highlight">✅ BLE 5.0</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
<td>❌</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>9 Platform Support</td>
|
||||
<td className="comparison-highlight">✅ All 9</td>
|
||||
<td>✅ 5</td>
|
||||
<td>✅ 5</td>
|
||||
<td>⚠️ 3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open Audit Trail</td>
|
||||
<td className="comparison-highlight">✅ Full</td>
|
||||
<td>⚠️ Partial</td>
|
||||
<td>⚠️ Partial</td>
|
||||
<td>❌</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── CTA Section ─── */}
|
||||
<section id="pricing" className="cta-section">
|
||||
<div className="container">
|
||||
<div className="cta-box">
|
||||
<h2>Siap Bergabung?</h2>
|
||||
<p>
|
||||
Mulai gunakan video conference terenkripsi sekarang.
|
||||
Gratis untuk 10 pengguna pertama.
|
||||
</p>
|
||||
<a href="/auth" className="btn btn-primary">
|
||||
🚀 Daftar Sekarang
|
||||
</a>
|
||||
<p style={{ marginTop: "24px", fontSize: "11px", color: "var(--text-muted)", opacity: 0.5, fontFamily: "monospace" }}>
|
||||
[TSM.ID].[11031972]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Video Conference Room Page
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
export default function RoomPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const code = params.code as string;
|
||||
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
const [micOn, setMicOn] = useState(true);
|
||||
const [camOn, setCamOn] = useState(true);
|
||||
const [screenOn, setScreenOn] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
|
||||
// Placeholder participants
|
||||
const participants = [
|
||||
{ id: '1', name: 'Anda', initials: 'A' },
|
||||
{ id: '2', name: 'Budi Santoso', initials: 'BS' },
|
||||
{ id: '3', name: 'Dewi Lestari', initials: 'DL' },
|
||||
{ id: '4', name: 'Adi Nugroho', initials: 'AN' },
|
||||
];
|
||||
|
||||
const handleLeave = async () => {
|
||||
setIsLeaving(true);
|
||||
try {
|
||||
await fetch(`/api/rooms/${code}/leave`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch {
|
||||
// Still navigate away on error
|
||||
}
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="room">
|
||||
{/* Room Header */}
|
||||
<div className="room-header">
|
||||
<div className="room-info">
|
||||
<span className="room-code">{code}</span>
|
||||
<div className="room-badges">
|
||||
<span className="badge badge-encrypted">🔒 E2E Encrypted</span>
|
||||
<span className="badge badge-participants">
|
||||
👥 {participants.length} peserta
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebTransport Notice */}
|
||||
<div className="room-notice">
|
||||
ℹ️ Video conference akan aktif setelah Sprint 3 integrasi WebTransport dengan XCU Engine SFU.
|
||||
Saat ini Anda berada di mode preview.
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
<div className="room-video-grid">
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="video-placeholder">
|
||||
<div className="avatar-circle">{p.initials}</div>
|
||||
<span className="video-name">
|
||||
{p.name}
|
||||
{p.id === '1' && ' (Anda)'}
|
||||
</span>
|
||||
{!camOn && p.id === '1' && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||
Kamera Mati
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chat Sidebar (toggle) */}
|
||||
{chatOpen && (
|
||||
<div
|
||||
className="glass-panel"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '24px',
|
||||
top: '80px',
|
||||
bottom: '120px',
|
||||
width: '320px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px' }}>💬 Chat Terenkripsi</h3>
|
||||
<button
|
||||
onClick={() => setChatOpen(false)}
|
||||
className="control-btn"
|
||||
style={{ width: '32px', height: '32px', fontSize: '14px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '14px', textAlign: 'center' }}>
|
||||
Chat E2E akan aktif di Sprint 3.
|
||||
<br />
|
||||
Semua pesan dienkripsi dengan XChaCha20-Poly1305.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ketik pesan..."
|
||||
className="form-input"
|
||||
disabled
|
||||
/>
|
||||
<button className="btn btn-primary" disabled style={{ padding: '8px 16px' }}>
|
||||
Kirim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="room-controls">
|
||||
<button
|
||||
className="control-btn"
|
||||
onClick={() => setMicOn(!micOn)}
|
||||
title={micOn ? 'Matikan Mikrofon' : 'Nyalakan Mikrofon'}
|
||||
style={{
|
||||
background: micOn ? 'var(--bg-tertiary)' : 'rgba(239, 68, 68, 0.2)',
|
||||
borderColor: micOn ? 'var(--border-color)' : 'rgba(239, 68, 68, 0.3)',
|
||||
}}
|
||||
>
|
||||
{micOn ? '🎤' : '🔇'}
|
||||
</button>
|
||||
<button
|
||||
className="control-btn"
|
||||
onClick={() => setCamOn(!camOn)}
|
||||
title={camOn ? 'Matikan Kamera' : 'Nyalakan Kamera'}
|
||||
style={{
|
||||
background: camOn ? 'var(--bg-tertiary)' : 'rgba(239, 68, 68, 0.2)',
|
||||
borderColor: camOn ? 'var(--border-color)' : 'rgba(239, 68, 68, 0.3)',
|
||||
}}
|
||||
>
|
||||
{camOn ? '📹' : '📷'}
|
||||
</button>
|
||||
<button
|
||||
className="control-btn"
|
||||
onClick={() => setScreenOn(!screenOn)}
|
||||
title={screenOn ? 'Hentikan Berbagi Layar' : 'Berbagi Layar'}
|
||||
style={{
|
||||
background: screenOn ? 'rgba(16, 185, 129, 0.2)' : 'var(--bg-tertiary)',
|
||||
borderColor: screenOn ? 'var(--emerald-500)' : 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
🖥️
|
||||
</button>
|
||||
<button
|
||||
className="control-btn"
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
title="Chat"
|
||||
style={{
|
||||
background: chatOpen ? 'rgba(6, 182, 212, 0.2)' : 'var(--bg-tertiary)',
|
||||
borderColor: chatOpen ? 'var(--cyan-500)' : 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
<div style={{ width: '1px', height: '32px', background: 'var(--border-color)', margin: '0 8px' }} />
|
||||
<button
|
||||
className="control-btn control-btn-leave"
|
||||
onClick={handleLeave}
|
||||
disabled={isLeaving}
|
||||
title="Keluar Rapat"
|
||||
>
|
||||
{isLeaving ? '⏳' : '📞'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Auth Utilities
|
||||
// JWT creation/verification with jose, password hashing with bcryptjs
|
||||
|
||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const JWT_ISSUER = 'jumpa.id';
|
||||
const JWT_AUDIENCE = 'app.jumpa.id';
|
||||
const JWT_EXPIRY = '24h';
|
||||
|
||||
const secretKey = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
export interface JumpaTokenPayload extends JWTPayload {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed JWT token for authenticated user
|
||||
*/
|
||||
export async function createToken(userId: string, tenantId: string): Promise<string> {
|
||||
const token = await new SignJWT({ userId, tenantId })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setExpirationTime(JWT_EXPIRY)
|
||||
.sign(secretKey);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
* Returns the payload if valid, null if invalid/expired
|
||||
*/
|
||||
export async function verifyToken(token: string): Promise<JumpaTokenPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secretKey, {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
|
||||
if (!payload.userId || !payload.tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload as JumpaTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt (cost factor 12)
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a bcrypt hash
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// [TSM.ID].[11031972] — Prisma Client Singleton
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,153 @@
|
||||
// [TSM.ID].[11031972] — XCU Engine API Client
|
||||
// Ref: ARSITEKTUR_PXE_FINAL_V8.md BAB IX
|
||||
|
||||
const XCU_API_URL = process.env.XCU_API_URL || 'https://api.xcomu.id';
|
||||
const XCU_API_KEY = process.env.XCU_API_KEY || '';
|
||||
|
||||
export interface XcuRoom {
|
||||
id: string;
|
||||
roomCode: string;
|
||||
maxParticipants: number;
|
||||
isE2eEncrypted: boolean;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface XcuParticipant {
|
||||
id: string;
|
||||
roomId: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
transport: string;
|
||||
}
|
||||
|
||||
export interface XcuJoinResponse {
|
||||
participant: XcuParticipant;
|
||||
sfuConfig: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface XcuUsage {
|
||||
totalRooms: number;
|
||||
totalParticipants: number;
|
||||
totalMinutes: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
class XcuApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public responseBody?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'XcuApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class XcuClient {
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor() {
|
||||
this.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XCU-API-Key': XCU_API_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${XCU_API_URL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.headers,
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
throw new XcuApiError(
|
||||
response.status,
|
||||
`XCU API error: ${response.status} ${response.statusText}`,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Validate the API key against XCU Engine */
|
||||
async validateKey(): Promise<{ valid: boolean; clientId: string }> {
|
||||
return this.request('/v1/auth/validate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new video conference room */
|
||||
async createRoom(opts: {
|
||||
maxParticipants?: number;
|
||||
byokPubkeyHash?: string;
|
||||
displayName?: string;
|
||||
}): Promise<XcuRoom> {
|
||||
return this.request<XcuRoom>('/v1/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
max_participants: opts.maxParticipants || 50,
|
||||
byok_pubkey_hash: opts.byokPubkeyHash || null,
|
||||
display_name: opts.displayName || 'JUMPA Meeting',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Get room info by room code */
|
||||
async getRoom(code: string): Promise<XcuRoom> {
|
||||
return this.request<XcuRoom>(`/v1/rooms/${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
/** Join an existing room */
|
||||
async joinRoom(
|
||||
code: string,
|
||||
opts: { externalUserId: string; displayName: string }
|
||||
): Promise<XcuJoinResponse> {
|
||||
return this.request<XcuJoinResponse>(
|
||||
`/v1/rooms/${encodeURIComponent(code)}/join`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
external_user_id: opts.externalUserId,
|
||||
display_name: opts.displayName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Leave a room */
|
||||
async leaveRoom(code: string, participantId: string): Promise<void> {
|
||||
await this.request<void>(
|
||||
`/v1/rooms/${encodeURIComponent(code)}/leave`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Get billing/usage stats */
|
||||
async getUsage(): Promise<XcuUsage> {
|
||||
return this.request<XcuUsage>('/v1/billing/usage');
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton XCU client instance */
|
||||
export const xcuClient = new XcuClient();
|
||||
@@ -0,0 +1,65 @@
|
||||
// [TSM.ID].[11031972] — JUMPA.ID Middleware
|
||||
// Protects /dashboard/* and /room/* routes, requires JWT in jumpa_session cookie
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const secretKey = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
/** Routes that don't require authentication */
|
||||
const PUBLIC_PATHS = ['/', '/auth', '/api/health'];
|
||||
|
||||
/** Path prefixes that are always public */
|
||||
const PUBLIC_PREFIXES = ['/_next', '/favicon.ico', '/api/auth'];
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
if (PUBLIC_PATHS.includes(pathname)) return true;
|
||||
return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Allow public paths
|
||||
if (isPublicPath(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for auth cookie on protected routes
|
||||
const sessionCookie = request.cookies.get('jumpa_session');
|
||||
|
||||
if (!sessionCookie?.value) {
|
||||
const loginUrl = new URL('/auth', request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(sessionCookie.value, secretKey, {
|
||||
issuer: 'jumpa.id',
|
||||
audience: 'app.jumpa.id',
|
||||
});
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
// Invalid/expired token — clear cookie and redirect
|
||||
const loginUrl = new URL('/auth', request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
response.cookies.delete('jumpa_session');
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user