[TSM.ID] jumpa-app - production deploy via Phantom V5.2
JUMPA App CI / build (push) Waiting to run

This commit is contained in:
pxe_gitea
2026-06-02 03:13:07 +07:00
parent 2f071c63ed
commit d3c3aa61b8
34 changed files with 3579 additions and 101 deletions
+11 -1
View File
@@ -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 .
+9
View File
@@ -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
View File
@@ -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"
+65
View File
@@ -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";
}
}
+19
View File
@@ -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
View File
@@ -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;
+445 -2
View File
@@ -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",
+5
View File
@@ -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"
}
+116
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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

+80
View File
@@ -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();
});
+48
View File
@@ -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 "╚════════════════════════════════════════════════════╝"
+139
View File
@@ -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 }
);
}
}
+70
View File
@@ -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;
}
}
+116
View File
@@ -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 }
);
}
}
+18
View File
@@ -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]' }
});
}
+62
View File
@@ -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 }
);
}
}
+50
View File
@@ -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 }
);
}
}
+49
View File
@@ -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 }
);
}
}
+67
View File
@@ -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 }
);
}
}
+243
View File
@@ -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>
);
}
+178
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+66 -20
View File
@@ -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
View File
@@ -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>
</>
);
}
+183
View File
@@ -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>
);
}
+67
View File
@@ -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);
}
+8
View File
@@ -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;
+153
View File
@@ -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();
+65
View File
@@ -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).*)',
],
};