From b47a31c34f68b5b73ee32852fa22f6b945123cd2 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:28:47 +0000 Subject: [PATCH] init stripe --- .../api/AdminSettingsController.java | 10 + frontend/package-lock.json | 67 ++-- frontend/package.json | 2 + .../components/shared/ManageBillingButton.tsx | 37 ++ .../core/components/shared/StripeCheckout.tsx | 178 +++++++++ .../shared/config/configNavSections.tsx | 10 +- .../configSections/AdminPlanSection.tsx | 287 +++++++++++++++ .../configSections/plan/ActivePlanSection.tsx | 89 +++++ .../plan/AvailablePlansSection.tsx | 131 +++++++ .../config/configSections/plan/PlanCard.tsx | 83 +++++ .../configSections/plan/StaticPlanSection.tsx | 344 ++++++++++++++++++ frontend/src/core/hooks/usePlans.ts | 49 +++ frontend/src/core/services/licenseService.ts | 91 +++++ 13 files changed, 1336 insertions(+), 42 deletions(-) create mode 100644 frontend/src/core/components/shared/ManageBillingButton.tsx create mode 100644 frontend/src/core/components/shared/StripeCheckout.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/ActivePlanSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/AvailablePlansSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/plan/StaticPlanSection.tsx create mode 100644 frontend/src/core/hooks/usePlans.ts create mode 100644 frontend/src/core/services/licenseService.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 27c924ae4..a4bdd9da5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -299,6 +299,16 @@ public class AdminSettingsController { + String.join(", ", VALID_SECTION_NAMES)); } + // Auto-enable premium features if license key is provided + if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) { + Object keyValue = sectionData.get("key"); + if (keyValue != null && !keyValue.toString().trim().isEmpty()) { + // Automatically set enabled to true when a key is provided + sectionData.put("enabled", true); + log.info("Auto-enabling premium features because license key was provided"); + } + } + int updatedCount = 0; for (Map.Entry entry : sectionData.entrySet()) { String propertyKey = entry.getKey(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43ee35e16..365a6ab15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,8 @@ "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.5.0", @@ -441,7 +443,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -488,7 +489,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -512,7 +512,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -596,7 +595,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -613,7 +611,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -631,7 +628,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -668,7 +664,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -703,7 +698,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -740,7 +734,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -816,7 +809,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -972,7 +964,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1016,7 +1007,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2047,7 +2037,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2098,7 +2087,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2166,7 +2154,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3077,6 +3064,29 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz", + "integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", + "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", @@ -3850,7 +3860,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4174,7 +4183,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4185,7 +4193,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4246,7 +4253,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4960,6 +4966,7 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4969,6 +4976,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4979,6 +4987,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4991,6 +5000,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" @@ -5017,7 +5027,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5702,7 +5711,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6748,8 +6756,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7144,7 +7151,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7315,7 +7321,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8638,7 +8643,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9446,7 +9450,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -11223,7 +11226,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11503,7 +11505,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11876,7 +11877,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11886,7 +11886,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13557,7 +13556,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13859,7 +13857,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13942,7 +13939,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14147,7 +14143,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14299,7 +14294,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14313,7 +14307,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend/package.json b/frontend/package.json index 825749f3e..32238cfd1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,8 @@ "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", "@mantine/hooks": "^8.3.1", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", "@reactour/tour": "^3.8.0", diff --git a/frontend/src/core/components/shared/ManageBillingButton.tsx b/frontend/src/core/components/shared/ManageBillingButton.tsx new file mode 100644 index 000000000..3cbc07f4c --- /dev/null +++ b/frontend/src/core/components/shared/ManageBillingButton.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import { Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import licenseService from '@app/services/licenseService'; +import { alert } from '@app/components/toast'; + +interface ManageBillingButtonProps { + returnUrl?: string; +} + +export const ManageBillingButton: React.FC = ({ + returnUrl = window.location.href, +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + try { + setLoading(true); + const response = await licenseService.createBillingPortalSession(returnUrl); + window.location.href = response.url; + } catch (error) { + console.error('Failed to open billing portal:', error); + alert({ + alertType: 'error', + title: t('billing.portal.error', 'Failed to open billing portal'), + }); + setLoading(false); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/core/components/shared/StripeCheckout.tsx b/frontend/src/core/components/shared/StripeCheckout.tsx new file mode 100644 index 000000000..ca1870b79 --- /dev/null +++ b/frontend/src/core/components/shared/StripeCheckout.tsx @@ -0,0 +1,178 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { loadStripe } from '@stripe/stripe-js'; +import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'; +import licenseService from '@app/services/licenseService'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; + +// Initialize Stripe - this should come from environment variables +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); + +interface StripeCheckoutProps { + opened: boolean; + onClose: () => void; + planId: string; + planName: string; + planPrice: number; + currency: string; + onSuccess?: (sessionId: string) => void; + onError?: (error: string) => void; +} + +type CheckoutState = { + status: 'idle' | 'loading' | 'ready' | 'success' | 'error'; + clientSecret?: string; + error?: string; + sessionId?: string; +}; + +const StripeCheckout: React.FC = ({ + opened, + onClose, + planId, + planName, + planPrice, + currency, + onSuccess, + onError, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ status: 'idle' }); + + const createCheckoutSession = async () => { + try { + setState({ status: 'loading' }); + + const response = await licenseService.createCheckoutSession({ + planId, + currency, + successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${window.location.origin}/settings/adminPlan`, + }); + + setState({ + status: 'ready', + clientSecret: response.clientSecret, + sessionId: response.sessionId, + }); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to create checkout session'; + setState({ + status: 'error', + error: errorMessage, + }); + onError?.(errorMessage); + } + }; + + const handlePaymentComplete = () => { + setState({ status: 'success' }); + onSuccess?.(state.sessionId || ''); + }; + + const handleClose = () => { + setState({ status: 'idle' }); + onClose(); + }; + + // Initialize checkout when modal opens + useEffect(() => { + if (opened && state.status === 'idle') { + createCheckoutSession(); + } else if (!opened) { + setState({ status: 'idle' }); + } + }, [opened]); + + const renderContent = () => { + switch (state.status) { + case 'loading': + return ( +
+ + + {t('payment.preparing', 'Preparing your checkout...')} + +
+ ); + + case 'ready': + if (!state.clientSecret) return null; + + return ( + + + + ); + + case 'success': + return ( + + + + {t( + 'payment.successMessage', + 'Your subscription has been activated successfully. You will receive a confirmation email shortly.' + )} + + + {t('payment.autoClose', 'This window will close automatically...')} + + + + ); + + case 'error': + return ( + + + {state.error} + + + + ); + + default: + return null; + } + }; + + return ( + + + {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })} + + + {currency} + {planPrice}/{t('plan.period.month', 'month')} + + + } + size="xl" + centered + withCloseButton={state.status !== 'ready'} + closeOnEscape={state.status !== 'ready'} + closeOnClickOutside={state.status !== 'ready'} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default StripeCheckout; diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index cbef8d27e..221817b04 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -9,11 +9,11 @@ import AdminPrivacySection from '@app/components/shared/config/configSections/Ad import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection'; import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection'; import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection'; -import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection'; import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection'; import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection'; +import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection'; export interface ConfigNavItem { key: NavKey; @@ -126,10 +126,10 @@ export const createConfigNavSections = ( title: 'Licensing & Analytics', items: [ { - key: 'adminPremium', - label: 'Premium', - icon: 'star-rounded', - component: + key: 'adminPlan', + label: 'Plan', + icon: 'receipt-long-rounded', + component: }, { key: 'adminAudit', diff --git a/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx new file mode 100644 index 000000000..6f44fac6d --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/AdminPlanSection.tsx @@ -0,0 +1,287 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { usePlans } from '@app/hooks/usePlans'; +import { PlanTier } from '@app/services/licenseService'; +import StripeCheckout from '@app/components/shared/StripeCheckout'; +import AvailablePlansSection from './plan/AvailablePlansSection'; +import ActivePlanSection from './plan/ActivePlanSection'; +import StaticPlanSection from './plan/StaticPlanSection'; +import { userManagementService } from '@app/services/userManagementService'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { alert } from '@app/components/toast'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; +import { useRestartServer } from '@app/components/shared/config/useRestartServer'; +import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import PendingBadge from '@app/components/shared/config/PendingBadge'; + +interface PremiumSettingsData { + key?: string; + enabled?: boolean; +} + +const AdminPlanSection: React.FC = () => { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const [checkoutOpen, setCheckoutOpen] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(null); + const [currency, setCurrency] = useState('gbp'); + const [useStaticVersion, setUseStaticVersion] = useState(false); + const [currentLicenseInfo, setCurrentLicenseInfo] = useState(null); + const [showLicenseKey, setShowLicenseKey] = useState(false); + const { plans, currentSubscription, loading, error, refetch } = usePlans(currency); + + // Premium/License key management + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { + settings: premiumSettings, + setSettings: setPremiumSettings, + loading: premiumLoading, + saving: premiumSaving, + fetchSettings: fetchPremiumSettings, + saveSettings: savePremiumSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'premium', + }); + + // Check if we should use static version and fetch license info + useEffect(() => { + const fetchLicenseInfo = async () => { + try { + const adminData = await userManagementService.getUsers(); + + // Determine plan name based on config flags + let planName = 'Free'; + if (config?.runningEE) { + planName = 'Enterprise'; + } else if (config?.runningProOrHigher || adminData.premiumEnabled) { + planName = 'Pro'; + } + + setCurrentLicenseInfo({ + planName, + maxUsers: adminData.maxAllowedUsers, + grandfathered: adminData.grandfatheredUserCount > 0, + }); + } catch (err) { + console.error('Failed to fetch license info:', err); + } + }; + + // Check if Stripe is configured + const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; + if (!stripeKey || error) { + setUseStaticVersion(true); + fetchLicenseInfo(); + } + + // Fetch premium settings + fetchPremiumSettings(); + }, [error, config]); + + const handleSaveLicense = async () => { + try { + await savePremiumSettings(); + showRestartModal(); + } catch (_error) { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save settings'), + }); + } + }; + + const currencyOptions = [ + { value: 'gbp', label: 'British pound (GBP, £)' }, + { value: 'usd', label: 'US dollar (USD, $)' }, + { value: 'eur', label: 'Euro (EUR, €)' }, + { value: 'cny', label: 'Chinese yuan (CNY, ¥)' }, + { value: 'inr', label: 'Indian rupee (INR, ₹)' }, + { value: 'brl', label: 'Brazilian real (BRL, R$)' }, + { value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' }, + ]; + + const handleUpgradeClick = useCallback( + (plan: PlanTier) => { + if (plan.isContactOnly) { + // Open contact form or redirect to contact page + window.open('https://www.stirling.com/contact', '_blank'); + return; + } + + if (!currentSubscription || plan.id !== currentSubscription.plan.id) { + setSelectedPlan(plan); + setCheckoutOpen(true); + } + }, + [currentSubscription] + ); + + const handlePaymentSuccess = useCallback( + (sessionId: string) => { + console.log('Payment successful, session:', sessionId); + + // Refetch plans to update current subscription + refetch(); + + // Close modal after brief delay to show success message + setTimeout(() => { + setCheckoutOpen(false); + setSelectedPlan(null); + }, 2000); + }, + [refetch] + ); + + const handlePaymentError = useCallback((error: string) => { + console.error('Payment error:', error); + // Error is already displayed in the StripeCheckout component + }, []); + + const handleCheckoutClose = useCallback(() => { + setCheckoutOpen(false); + setSelectedPlan(null); + }, []); + + // Show static version if Stripe is not configured or there's an error + if (useStaticVersion) { + return ; + } + + // Early returns after all hooks are called + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + // Fallback to static version on error + return ; + } + + if (!plans || !currentSubscription) { + return ( + + Plans data is not available at the moment. + + ); + } + + return ( +
+ {/* Currency Selector */} +
+ + + {t('plan.currency', 'Currency')} + +