diff --git a/.env.example b/.env.example index bcf2e5a..cd302bf 100644 --- a/.env.example +++ b/.env.example @@ -29,9 +29,26 @@ E2B_API_KEY=your-e2b-api-key-here # ========================================== # Port for the Genesis Web UI GENESIS_WEBUI_PORT=8000 +# Public URL of the app (used for Stripe redirect URLs) +APP_URL=http://localhost:5173 + +# ========================================== +# Authentication (Clerk) +# ========================================== +# Get these from https://dashboard.clerk.com +VITE_CLERK_PUBLISHABLE_KEY=pk_test_your-clerk-publishable-key +CLERK_SECRET_KEY=sk_test_your-clerk-secret-key + +# ========================================== +# Billing (Stripe) +# ========================================== +# Get these from https://dashboard.stripe.com +STRIPE_SECRET_KEY=sk_test_your-stripe-secret-key +STRIPE_WEBHOOK_SECRET=whsec_your-stripe-webhook-secret +STRIPE_PRICE_ID=price_your-stripe-price-id # ========================================== # Advanced Database Configuration (Optional) # ========================================== -# Full database URL (alternative to individual postgres settings) -# DATABASE_URL=postgresql://genesis:password@postgres:5432/genesis +# Full database URL (used for user/subscription storage and as alternative to individual postgres settings) +DATABASE_URL=postgresql://genesis:changeme_secure_password@localhost:5432/genesis diff --git a/genesis/webui/frontend/package-lock.json b/genesis/webui/frontend/package-lock.json index 438fc32..ce3acfc 100644 --- a/genesis/webui/frontend/package-lock.json +++ b/genesis/webui/frontend/package-lock.json @@ -8,12 +8,15 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@clerk/clerk-react": "^5.61.4", + "@clerk/express": "^2.0.7", "@tailwindcss/postcss": "^4.1.17", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/d3": "^7.4.3", "@types/diff": "^7.0.2", "@types/express": "^5.0.5", + "@types/pg": "^8.20.0", "better-sqlite3": "^12.4.6", "chart.js": "^4.5.1", "clsx": "^2.1.1", @@ -27,12 +30,15 @@ "highlight.js": "^11.11.1", "lucide-react": "^0.554.0", "marked": "^17.0.1", + "pg": "^8.20.0", "plotly.js": "^3.3.0", "react": "^19.1.1", "react-chartjs-2": "^5.3.1", "react-dom": "^19.1.1", "react-plotly.js": "^2.6.0", "react-router-dom": "^7.9.4", + "stripe": "^21.0.1", + "tailwind-merge": "^3.5.0", "tsx": "^4.20.6" }, "devDependencies": { @@ -103,7 +109,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -373,6 +378,142 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/@clerk/backend": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.3.tgz", + "integrity": "sha512-I3YLnSioYFG+EVFBYm0ilN28+FC8H+hkqMgB5Pdl7AcotQOn3JhiZMqLel2H0P390p8FEJKQNnrvXk3BemeKKQ==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^4.3.2", + "standardwebhooks": "^1.0.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.9.0" + } + }, + "node_modules/@clerk/backend/node_modules/@clerk/shared": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.3.2.tgz", + "integrity": "sha512-tYYzdY4Fxb02TO4RHoLRFzEjXJn0iFDfoKhWtGyqf2AaIgkprTksunQtX0hnVssHMr3XD/E2S00Vrb+PzX3jCQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0" + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/clerk-react": { + "version": "5.61.4", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.61.4.tgz", + "integrity": "sha512-xGvQvzfc5pQEuqCW8CNUgnlR+9nt6gSSMGMYx3l972utIJrFKByQJFCRZpwYBvAHiveuK11Wgy3J39p904jb+w==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.47.3", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + } + }, + "node_modules/@clerk/express": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@clerk/express/-/express-2.0.7.tgz", + "integrity": "sha512-YxVtpxXm6nhYw19dlLlv52CizujUVzMQVCpVAFIfGAQ1zEtCqoVgD0PAQ/l6FMjzhTQA68j7zTbstVBlo+HlKg==", + "license": "MIT", + "dependencies": { + "@clerk/backend": "^3.2.3", + "@clerk/shared": "^4.3.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "express": "^4.17.0 || ^5.0.0" + } + }, + "node_modules/@clerk/express/node_modules/@clerk/shared": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.3.2.tgz", + "integrity": "sha512-tYYzdY4Fxb02TO4RHoLRFzEjXJn0iFDfoKhWtGyqf2AaIgkprTksunQtX0hnVssHMr3XD/E2S00Vrb+PzX3jCQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.16", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0" + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/shared": { + "version": "3.47.3", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.3.tgz", + "integrity": "sha512-jG0wMIZuuc8zaKieg9Os8ocTphG+llluRukUUdyVnu4+ZI1syVf+dkpDP3ZK69yLavTX3D0KAmkmQqTPzQV/Nw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "csstype": "3.1.3", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0", + "swr": "2.3.4" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -2322,6 +2463,12 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -2578,6 +2725,16 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@turf/area": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.0.tgz", @@ -3086,6 +3243,17 @@ "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/plotly.js": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-3.0.8.tgz", @@ -3111,7 +3279,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3122,7 +3289,6 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3212,7 +3378,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -3484,7 +3649,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3804,7 +3968,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3948,7 +4111,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -4334,7 +4496,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/d": { @@ -4713,7 +4874,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4873,6 +5033,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-kerning": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", @@ -5203,7 +5372,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5659,6 +5827,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -6043,6 +6217,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -6696,6 +6876,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7860,6 +8049,95 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pick-by-alias": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", @@ -8096,7 +8374,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8113,6 +8390,45 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -8360,11 +8676,10 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8380,16 +8695,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.4" } }, "node_modules/react-is": { @@ -9110,6 +9424,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stack-trace": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", @@ -9118,6 +9441,16 @@ "node": "*" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/static-eval": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", @@ -9136,6 +9469,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, "node_modules/stream-parser": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", @@ -9236,6 +9575,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.1.tgz", + "integrity": "sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strongly-connected-components": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", @@ -9327,6 +9683,29 @@ "svg-path-bounds": "^1.0.1" } }, + "node_modules/swr": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz", + "integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", @@ -9456,7 +9835,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9603,7 +9981,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -9685,7 +10062,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9829,6 +10205,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9850,7 +10235,6 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9944,7 +10328,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/genesis/webui/frontend/package.json b/genesis/webui/frontend/package.json index 64adca8..2eb041c 100644 --- a/genesis/webui/frontend/package.json +++ b/genesis/webui/frontend/package.json @@ -16,12 +16,15 @@ "preview": "vite preview" }, "dependencies": { + "@clerk/clerk-react": "^5.61.4", + "@clerk/express": "^2.0.7", "@tailwindcss/postcss": "^4.1.17", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/d3": "^7.4.3", "@types/diff": "^7.0.2", "@types/express": "^5.0.5", + "@types/pg": "^8.20.0", "better-sqlite3": "^12.4.6", "chart.js": "^4.5.1", "clsx": "^2.1.1", @@ -35,12 +38,15 @@ "highlight.js": "^11.11.1", "lucide-react": "^0.554.0", "marked": "^17.0.1", + "pg": "^8.20.0", "plotly.js": "^3.3.0", "react": "^19.1.1", "react-chartjs-2": "^5.3.1", "react-dom": "^19.1.1", "react-plotly.js": "^2.6.0", "react-router-dom": "^7.9.4", + "stripe": "^21.0.1", + "tailwind-merge": "^3.5.0", "tsx": "^4.20.6" }, "devDependencies": { diff --git a/genesis/webui/frontend/server/billing.ts b/genesis/webui/frontend/server/billing.ts new file mode 100644 index 0000000..aef2695 --- /dev/null +++ b/genesis/webui/frontend/server/billing.ts @@ -0,0 +1,206 @@ +import { Router, raw } from 'express'; +import Stripe from 'stripe'; +import { requireAuth, getAuth } from '@clerk/express'; +import { + ensureUser, + getSubscription, + upsertSubscription, + getSubscriptionByStripeCustomer, +} from './db.js'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2026-03-25.dahlia', +}); + +const router = Router(); + +// GET /api/billing/status -- returns current subscription status for the authenticated user +router.get('/status', requireAuth(), async (req, res) => { + try { + const { userId } = getAuth(req); + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const user = await ensureUser(userId); + const subscription = await getSubscription(user.id); + + res.json({ + subscribed: subscription?.status === 'active', + status: subscription?.status ?? 'none', + plan: subscription?.plan ?? 'free', + currentPeriodEnd: subscription?.current_period_end ?? null, + }); + } catch (error) { + console.error('[BILLING] Error fetching status:', error); + res.status(500).json({ error: 'Failed to fetch billing status' }); + } +}); + +// POST /api/billing/checkout -- creates a Stripe Checkout session +router.post('/checkout', requireAuth(), async (req, res) => { + try { + const { userId } = getAuth(req); + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const user = await ensureUser(userId); + const subscription = await getSubscription(user.id); + + const customerData: Stripe.Checkout.SessionCreateParams.CustomerCreation = 'always'; + const sessionParams: Stripe.Checkout.SessionCreateParams = { + mode: 'subscription', + line_items: [ + { + price: process.env.STRIPE_PRICE_ID!, + quantity: 1, + }, + ], + success_url: `${process.env.APP_URL || 'http://localhost:5173'}/?checkout=success`, + cancel_url: `${process.env.APP_URL || 'http://localhost:5173'}/?checkout=canceled`, + metadata: { + clerk_user_id: userId, + internal_user_id: String(user.id), + }, + }; + + if (subscription?.stripe_customer_id) { + sessionParams.customer = subscription.stripe_customer_id; + } else { + sessionParams.customer_creation = customerData; + sessionParams.customer_email = user.email ?? undefined; + } + + const session = await stripe.checkout.sessions.create(sessionParams); + res.json({ url: session.url }); + } catch (error) { + console.error('[BILLING] Error creating checkout session:', error); + res.status(500).json({ error: 'Failed to create checkout session' }); + } +}); + +// POST /api/billing/portal -- creates a Stripe Billing Portal session +router.post('/portal', requireAuth(), async (req, res) => { + try { + const { userId } = getAuth(req); + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const user = await ensureUser(userId); + const subscription = await getSubscription(user.id); + + if (!subscription?.stripe_customer_id) { + return res.status(400).json({ error: 'No billing account found' }); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: subscription.stripe_customer_id, + return_url: process.env.APP_URL || 'http://localhost:5173', + }); + + res.json({ url: session.url }); + } catch (error) { + console.error('[BILLING] Error creating portal session:', error); + res.status(500).json({ error: 'Failed to create portal session' }); + } +}); + +// POST /api/webhooks/stripe -- Stripe webhook handler (no auth -- verified by signature) +const webhookRouter = Router(); + +webhookRouter.post( + '/stripe', + raw({ type: 'application/json' }), + async (req, res) => { + const sig = req.headers['stripe-signature'] as string; + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent( + req.body, + sig, + process.env.STRIPE_WEBHOOK_SECRET!, + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('[WEBHOOK] Signature verification failed:', message); + return res.status(400).json({ error: `Webhook Error: ${message}` }); + } + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const clerkUserId = session.metadata?.clerk_user_id; + if (!clerkUserId) break; + + const user = await ensureUser(clerkUserId); + const customerId = + typeof session.customer === 'string' + ? session.customer + : session.customer?.id; + + if (customerId && session.subscription) { + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription.id; + + await upsertSubscription(user.id, { + stripe_customer_id: customerId, + stripe_subscription_id: subscriptionId, + status: 'active', + plan: 'pro', + }); + } + break; + } + + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription; + const customerId = + typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer.id; + + const existing = await getSubscriptionByStripeCustomer(customerId); + if (existing) { + await upsertSubscription(existing.user_id, { + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + status: subscription.status, + }); + } + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription; + const customerId = + typeof subscription.customer === 'string' + ? subscription.customer + : subscription.customer.id; + + const existing = await getSubscriptionByStripeCustomer(customerId); + if (existing) { + await upsertSubscription(existing.user_id, { + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + status: 'canceled', + }); + } + break; + } + } + } catch (error) { + console.error('[WEBHOOK] Error processing event:', error); + return res.status(500).json({ error: 'Webhook processing failed' }); + } + + res.json({ received: true }); + }, +); + +export { router as billingRouter, webhookRouter }; diff --git a/genesis/webui/frontend/server/db.ts b/genesis/webui/frontend/server/db.ts new file mode 100644 index 0000000..d0f9a63 --- /dev/null +++ b/genesis/webui/frontend/server/db.ts @@ -0,0 +1,121 @@ +import pg from 'pg'; + +const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, +}); + +export interface User { + id: number; + clerk_user_id: string; + email: string | null; + created_at: Date; +} + +export interface Subscription { + id: number; + user_id: number; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + status: string; + plan: string; + current_period_end: Date | null; + updated_at: Date; +} + +export async function ensureUser(clerkUserId: string, email?: string): Promise { + const existing = await pool.query( + 'SELECT * FROM users WHERE clerk_user_id = $1', + [clerkUserId], + ); + if (existing.rows.length > 0) { + return existing.rows[0]; + } + + const inserted = await pool.query( + 'INSERT INTO users (clerk_user_id, email) VALUES ($1, $2) RETURNING *', + [clerkUserId, email ?? null], + ); + return inserted.rows[0]; +} + +export async function getSubscription(userId: number): Promise { + const result = await pool.query( + 'SELECT * FROM subscriptions WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 1', + [userId], + ); + return result.rows[0] ?? null; +} + +export async function getSubscriptionByStripeCustomer( + stripeCustomerId: string, +): Promise { + const result = await pool.query( + 'SELECT * FROM subscriptions WHERE stripe_customer_id = $1', + [stripeCustomerId], + ); + return result.rows[0] ?? null; +} + +export async function upsertSubscription( + userId: number, + data: { + stripe_customer_id: string; + stripe_subscription_id?: string; + status: string; + plan?: string; + current_period_end?: Date; + }, +): Promise { + const existing = await pool.query( + 'SELECT * FROM subscriptions WHERE user_id = $1', + [userId], + ); + + if (existing.rows.length > 0) { + const result = await pool.query( + `UPDATE subscriptions + SET stripe_customer_id = $1, + stripe_subscription_id = COALESCE($2, stripe_subscription_id), + status = $3, + plan = COALESCE($4, plan), + current_period_end = COALESCE($5, current_period_end), + updated_at = now() + WHERE user_id = $6 + RETURNING *`, + [ + data.stripe_customer_id, + data.stripe_subscription_id ?? null, + data.status, + data.plan ?? null, + data.current_period_end ?? null, + userId, + ], + ); + return result.rows[0]; + } + + const result = await pool.query( + `INSERT INTO subscriptions + (user_id, stripe_customer_id, stripe_subscription_id, status, plan, current_period_end) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + userId, + data.stripe_customer_id, + data.stripe_subscription_id ?? null, + data.status, + data.plan ?? 'free', + data.current_period_end ?? null, + ], + ); + return result.rows[0]; +} + +export async function getUserByStripeCustomer(stripeCustomerId: string): Promise { + const sub = await getSubscriptionByStripeCustomer(stripeCustomerId); + if (!sub) return null; + const result = await pool.query('SELECT * FROM users WHERE id = $1', [sub.user_id]); + return result.rows[0] ?? null; +} + +export { pool }; diff --git a/genesis/webui/frontend/server/index.ts b/genesis/webui/frontend/server/index.ts index f0b84dd..4471707 100644 --- a/genesis/webui/frontend/server/index.ts +++ b/genesis/webui/frontend/server/index.ts @@ -6,6 +6,9 @@ import { fileURLToPath } from 'url'; import { glob } from 'glob'; import Database from 'better-sqlite3'; import { marked } from 'marked'; +import { clerkMiddleware, requireAuth, getAuth } from '@clerk/express'; +import { billingRouter, webhookRouter } from './billing.js'; +import { ensureUser, getSubscription } from './db.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,14 +25,50 @@ const cache = new Map(); const CACHE_TTL = 5000; app.use(cors()); + +// Stripe webhooks need raw body -- mount before express.json() +app.use('/api/webhooks', webhookRouter); + app.use(express.json()); +// Clerk session verification on all routes +app.use(clerkMiddleware()); + // Logging middleware app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); next(); }); +// Billing routes (auth required but no subscription check) +app.use('/api/billing', billingRouter); + +// Subscription gate middleware for all data routes below +const requireSubscription: express.RequestHandler = async (req, res, next) => { + try { + const { userId } = getAuth(req); + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const user = await ensureUser(userId); + const subscription = await getSubscription(user.id); + + if (subscription?.status !== 'active') { + return res.status(403).json({ error: 'Active subscription required' }); + } + + next(); + } catch (error) { + console.error('[AUTH] Subscription check failed:', error); + res.status(500).json({ error: 'Authorization check failed' }); + } +}; + +// All data routes require auth + active subscription +app.use(requireAuth()); +app.use(requireSubscription); + // Types interface DatabaseInfo { path: string; diff --git a/genesis/webui/frontend/src/App.tsx b/genesis/webui/frontend/src/App.tsx index aeee7c0..900b4ab 100644 --- a/genesis/webui/frontend/src/App.tsx +++ b/genesis/webui/frontend/src/App.tsx @@ -1,20 +1,51 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { SignedIn, SignedOut, SignInButton, UserButton } from '@clerk/clerk-react'; import { GenesisProvider } from './context/GenesisContext'; import GenesisLayout from './components/GenesisLayout'; import CommandMenu from './components/CommandMenu'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { BillingGate } from './components/BillingGate'; + +function SignInPage() { + return ( +
+
+ Genesis +

Genesis

+

Sign in to access your evolution experiments

+ + + +
+
+ ); +} export default function App() { return ( - - - - - } /> - - - + + + + + + +
+
+ +
+ + + + } /> + + +
+
+
+
); } diff --git a/genesis/webui/frontend/src/components/BillingGate.tsx b/genesis/webui/frontend/src/components/BillingGate.tsx new file mode 100644 index 0000000..ff83987 --- /dev/null +++ b/genesis/webui/frontend/src/components/BillingGate.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { useAuth } from '@clerk/clerk-react'; + +const API_BASE = import.meta.env.VITE_API_URL || '/api'; + +interface BillingStatus { + subscribed: boolean; + status: string; + plan: string; + currentPeriodEnd: string | null; +} + +export function BillingGate({ children }: { children: ReactNode }) { + const { getToken } = useAuth(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [redirecting, setRedirecting] = useState(false); + + useEffect(() => { + async function checkSubscription() { + try { + const token = await getToken(); + const res = await fetch(`${API_BASE}/billing/status`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data: BillingStatus = await res.json(); + setStatus(data); + } + } catch (err) { + console.error('Failed to check billing status:', err); + } finally { + setLoading(false); + } + } + checkSubscription(); + }, [getToken]); + + async function handleSubscribe() { + setRedirecting(true); + try { + const token = await getToken(); + const res = await fetch(`${API_BASE}/billing/checkout`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } + } catch (err) { + console.error('Failed to create checkout session:', err); + setRedirecting(false); + } + } + + async function handleManageBilling() { + try { + const token = await getToken(); + const res = await fetch(`${API_BASE}/billing/portal`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } + } catch (err) { + console.error('Failed to open billing portal:', err); + } + } + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (status?.subscribed) { + return <>{children}; + } + + return ( +
+
+ Genesis +

Subscription Required

+

+ A Genesis Pro subscription is required to access evolution experiments and analytics. +

+ + {status?.status === 'canceled' && ( + + )} +
+
+ ); +} diff --git a/genesis/webui/frontend/src/components/ErrorBoundary.tsx b/genesis/webui/frontend/src/components/ErrorBoundary.tsx index f8990e4..58bfd85 100644 --- a/genesis/webui/frontend/src/components/ErrorBoundary.tsx +++ b/genesis/webui/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; interface Props { children: ReactNode; diff --git a/genesis/webui/frontend/src/context/GenesisContext.tsx b/genesis/webui/frontend/src/context/GenesisContext.tsx index 832e2f2..6739037 100644 --- a/genesis/webui/frontend/src/context/GenesisContext.tsx +++ b/genesis/webui/frontend/src/context/GenesisContext.tsx @@ -183,7 +183,7 @@ export function GenesisProvider({ children }: { children: ReactNode }) { const stats = computeStats(state.programs); - const loadDatabases = useCallback(async (force = false) => { + const loadDatabases = useCallback(async (_force = false) => { dispatch({ type: 'SET_LOADING', payload: true }); dispatch({ type: 'SET_ERROR', payload: null }); diff --git a/genesis/webui/frontend/src/main.tsx b/genesis/webui/frontend/src/main.tsx index df655ea..a4555fa 100644 --- a/genesis/webui/frontend/src/main.tsx +++ b/genesis/webui/frontend/src/main.tsx @@ -1,10 +1,19 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { ClerkProvider } from '@clerk/clerk-react'; import './index.css'; import App from './App.tsx'; +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!PUBLISHABLE_KEY) { + throw new Error('VITE_CLERK_PUBLISHABLE_KEY is required'); +} + createRoot(document.getElementById('root')!).render( - + + + ); diff --git a/migrations/001_users_subscriptions.sql b/migrations/001_users_subscriptions.sql new file mode 100644 index 0000000..e3c6462 --- /dev/null +++ b/migrations/001_users_subscriptions.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + clerk_user_id TEXT UNIQUE NOT NULL, + email TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + stripe_customer_id TEXT UNIQUE, + stripe_subscription_id TEXT, + status TEXT NOT NULL DEFAULT 'inactive', + plan TEXT NOT NULL DEFAULT 'free', + current_period_end TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_users_clerk_id ON users(clerk_user_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id);