mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
feat(SaaS): Billing settings screen (#6495)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
|
||||
describe("PaymentForm", () => {
|
||||
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
|
||||
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const renderPaymentForm = () =>
|
||||
render(<PaymentForm />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// useBalance hook will return the balance only if the APP_MODE is "saas"
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the users current balance", async () => {
|
||||
getBalanceSpy.mockResolvedValue("100.50");
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const balance = screen.getByTestId("user-balance");
|
||||
expect(balance).toHaveTextContent("$100.50");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the users current balance to two decimal places", async () => {
|
||||
getBalanceSpy.mockResolvedValue("100");
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const balance = screen.getByTestId("user-balance");
|
||||
expect(balance).toHaveTextContent("$100.00");
|
||||
});
|
||||
});
|
||||
|
||||
test("the user can top-up a specific amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.12);
|
||||
});
|
||||
|
||||
it("should round the top-up amount to two decimal places", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.125456");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50.13);
|
||||
});
|
||||
|
||||
it("should render the payment method link", async () => {
|
||||
renderPaymentForm();
|
||||
|
||||
screen.getByTestId("payment-methods-link");
|
||||
});
|
||||
|
||||
it("should disable the top-up button if the user enters an invalid amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
expect(topUpButton).toBeDisabled();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, " ");
|
||||
|
||||
expect(topUpButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable the top-up button after submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(topUpButton).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("prevent submission if", () => {
|
||||
test("user enters a negative amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "-50.12");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters an empty string", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, " ");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters a non-numeric value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "abc");
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters less than the minimum amount", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const topUpInput = await screen.findByTestId("top-up-input");
|
||||
await user.type(topUpInput, "20"); // test assumes the minimum is 25
|
||||
|
||||
const topUpButton = screen.getByText("Add credit");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
|
||||
describe("SettingsInput", () => {
|
||||
@@ -85,4 +86,24 @@ describe("SettingsInput", () => {
|
||||
|
||||
expect(screen.getByText("Start Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange with the input value", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
onChange={onChangeMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("test-input");
|
||||
await user.type(input, "Test");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(4);
|
||||
expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test");
|
||||
});
|
||||
});
|
||||
|
||||
83
frontend/__tests__/routes/settings-with-payment.test.tsx
Normal file
83
frontend/__tests__/routes/settings-with-payment.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import * as FeatureFlags from "#/utils/feature-flags";
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
|
||||
|
||||
const RoutesStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: () => <PaymentForm />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () =>
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should not render the navbar if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const navbar = screen.queryByTestId("settings-navbar");
|
||||
expect(navbar).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the navbar if SaaS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const navbar = screen.getByTestId("settings-navbar");
|
||||
within(navbar).getByText("Account");
|
||||
within(navbar).getByText("Credits");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the credits item", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
const credits = within(navbar).getByText("Credits");
|
||||
await user.click(credits);
|
||||
|
||||
const billingSection = await screen.findByTestId("billing-settings");
|
||||
within(billingSection).getByText("Manage Credits");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { PostApiSettings } from "#/types/settings";
|
||||
import * as ConsentHandlers from "#/utils/handle-capture-consent";
|
||||
import AccountSettings from "#/routes/account-settings";
|
||||
|
||||
const toggleAdvancedSettings = async (user: UserEvent) => {
|
||||
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
|
||||
@@ -36,6 +37,7 @@ describe("Settings Screen", () => {
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
children: [{ Component: AccountSettings, path: "/settings" }],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
32
frontend/__tests__/utils/amount-is-valid.test.ts
Normal file
32
frontend/__tests__/utils/amount-is-valid.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
|
||||
describe("amountIsValid", () => {
|
||||
describe("fails", () => {
|
||||
test("when the amount is negative", () => {
|
||||
expect(amountIsValid("-5")).toBe(false);
|
||||
expect(amountIsValid("-25")).toBe(false);
|
||||
});
|
||||
|
||||
test("when the amount is zero", () => {
|
||||
expect(amountIsValid("0")).toBe(false);
|
||||
});
|
||||
|
||||
test("when an empty string is passed", () => {
|
||||
expect(amountIsValid("")).toBe(false);
|
||||
expect(amountIsValid(" ")).toBe(false);
|
||||
});
|
||||
|
||||
test("when a non-numeric value is passed", () => {
|
||||
expect(amountIsValid("abc")).toBe(false);
|
||||
expect(amountIsValid("1abc")).toBe(false);
|
||||
expect(amountIsValid("abc1")).toBe(false);
|
||||
});
|
||||
|
||||
test("when an amount less than the minimum is passed", () => {
|
||||
// test assumes the minimum is 25
|
||||
expect(amountIsValid("24")).toBe(false);
|
||||
expect(amountIsValid("24.99")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
@@ -14,6 +14,8 @@
|
||||
"@react-router/serve": "^7.1.5",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
"@stripe/stripe-js": "^5.5.0",
|
||||
"@tanstack/react-query": "^5.66.7",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -84,6 +86,7 @@
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.1",
|
||||
"stripe": "^17.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
@@ -5871,6 +5874,29 @@
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.1.1.tgz",
|
||||
"integrity": "sha512-+JzYFgUivVD7koqYV7LmLlt9edDMAwKH7XhZAHFQMo7NeRC+6D2JmQGzp9tygWerzwttwFLlExGp4rAOvD6l9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
|
||||
"react": ">=16.8.0 <20.0.0",
|
||||
"react-dom": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz",
|
||||
"integrity": "sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||
@@ -12324,7 +12350,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -14679,7 +14704,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -14691,7 +14715,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
@@ -16572,6 +16595,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "17.6.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-17.6.0.tgz",
|
||||
"integrity": "sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=8.1.0",
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.*"
|
||||
}
|
||||
},
|
||||
"node_modules/style-to-object": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"@react-router/serve": "^7.1.5",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@stripe/react-stripe-js": "^3.1.1",
|
||||
"@stripe/stripe-js": "^5.5.0",
|
||||
"@tanstack/react-query": "^5.66.7",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -49,7 +51,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=false react-router dev",
|
||||
"dev:mock:saas": "npm run make-i18n && cross-env VITE_MOCK_API=true VITE_MOCK_SAAS=true react-router dev",
|
||||
"build": "npm run make-i18n && npm run typecheck && react-router build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
@@ -111,6 +114,7 @@
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.1",
|
||||
"stripe": "^17.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -274,6 +274,23 @@ class OpenHands {
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
{
|
||||
amount,
|
||||
},
|
||||
);
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
static async getBalance(): Promise<string> {
|
||||
const { data } = await openHands.get<{ credits: string }>(
|
||||
"/api/billing/credits",
|
||||
);
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getGitHubUser(): Promise<GitHubUser> {
|
||||
const response = await openHands.get<GitHubUser>("/api/github/user");
|
||||
|
||||
|
||||
92
frontend/src/components/features/payment/payment-form.tsx
Normal file
92
frontend/src/components/features/payment/payment-form.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { HelpLink } from "../settings/help-link";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
|
||||
|
||||
const billingFormAction = async (formData: FormData) => {
|
||||
const amount = formData.get("top-up-input")?.toString();
|
||||
|
||||
if (amount?.trim()) {
|
||||
if (!amountIsValid(amount)) return;
|
||||
|
||||
const float = parseFloat(amount);
|
||||
addBalance({ amount: Number(float.toFixed(2)) });
|
||||
}
|
||||
|
||||
setButtonIsDisabled(true);
|
||||
};
|
||||
|
||||
const handleTopUpInputChange = (value: string) => {
|
||||
setButtonIsDisabled(!amountIsValid(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
action={billingFormAction}
|
||||
data-testid="billing-settings"
|
||||
className="flex flex-col gap-6 px-11 py-9"
|
||||
>
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Manage Credits
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
|
||||
"text-[28px] leading-8 -tracking-[0.02em] font-bold",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MoneyIcon width={22} height={14} />
|
||||
<span>Balance</span>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<SettingsInput
|
||||
testId="top-up-input"
|
||||
name="top-up-input"
|
||||
onChange={handleTopUpInputChange}
|
||||
type="text"
|
||||
label="Top-up amount"
|
||||
placeholder="Specify an amount to top up your credits"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center w-[680px] gap-2">
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={isPending || buttonIsDisabled}
|
||||
>
|
||||
Add credit
|
||||
</BrandButton>
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HelpLink
|
||||
testId="payment-methods-link"
|
||||
href="https://stripe.com/"
|
||||
text="Manage payment methods on"
|
||||
linkText="Stripe"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface SettingsInputProps {
|
||||
isDisabled?: boolean;
|
||||
startContent?: React.ReactNode;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsInput({
|
||||
@@ -25,6 +26,7 @@ export function SettingsInput({
|
||||
isDisabled,
|
||||
startContent,
|
||||
className,
|
||||
onChange,
|
||||
}: SettingsInputProps) {
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
|
||||
@@ -35,13 +37,14 @@ export function SettingsInput({
|
||||
</div>
|
||||
<input
|
||||
data-testid={testId}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-[#B7BDC2]",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,7 @@ async function prepareApp() {
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient(queryClientConfig);
|
||||
export const queryClient = new QueryClient(queryClientConfig);
|
||||
|
||||
prepareApp().then(() =>
|
||||
startTransition(() => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useCreateStripeCheckoutSession = () =>
|
||||
useMutation({
|
||||
mutationFn: async (variables: { amount: number }) => {
|
||||
const redirectUrl = await OpenHands.createCheckoutSession(
|
||||
variables.amount,
|
||||
);
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
13
frontend/src/hooks/query/use-balance.ts
Normal file
13
frontend/src/hooks/query/use-balance.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useBalance = () => {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "balance"],
|
||||
queryFn: OpenHands.getBalance,
|
||||
enabled: config?.APP_MODE === "saas",
|
||||
});
|
||||
};
|
||||
3
frontend/src/icons/money.svg
Normal file
3
frontend/src/icons/money.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="22" height="14" viewBox="0 0 22 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 6C4.80222 6 4.60888 6.05865 4.44443 6.16853C4.27998 6.27841 4.15181 6.43459 4.07612 6.61732C4.00043 6.80004 3.98063 7.00111 4.01921 7.19509C4.0578 7.38907 4.15304 7.56725 4.29289 7.70711C4.43275 7.84696 4.61093 7.9422 4.80491 7.98079C4.99889 8.01937 5.19996 7.99957 5.38268 7.92388C5.56541 7.84819 5.72159 7.72002 5.83147 7.55557C5.94135 7.39112 6 7.19778 6 7C6 6.73478 5.89464 6.48043 5.70711 6.29289C5.51957 6.10536 5.26522 6 5 6ZM17 6C16.8022 6 16.6089 6.05865 16.4444 6.16853C16.28 6.27841 16.1518 6.43459 16.0761 6.61732C16.0004 6.80004 15.9806 7.00111 16.0192 7.19509C16.0578 7.38907 16.153 7.56725 16.2929 7.70711C16.4327 7.84696 16.6109 7.9422 16.8049 7.98079C16.9989 8.01937 17.2 7.99957 17.3827 7.92388C17.5654 7.84819 17.7216 7.72002 17.8315 7.55557C17.9414 7.39112 18 7.19778 18 7C18 6.73478 17.8946 6.48043 17.7071 6.29289C17.5196 6.10536 17.2652 6 17 6ZM19 0H3C2.20435 0 1.44129 0.316071 0.87868 0.87868C0.31607 1.44129 0 2.20435 0 3V11C0 11.7956 0.31607 12.5587 0.87868 13.1213C1.44129 13.6839 2.20435 14 3 14H19C19.7956 14 20.5587 13.6839 21.1213 13.1213C21.6839 12.5587 22 11.7956 22 11V3C22 2.20435 21.6839 1.44129 21.1213 0.87868C20.5587 0.316071 19.7956 0 19 0ZM20 11C20 11.2652 19.8946 11.5196 19.7071 11.7071C19.5196 11.8946 19.2652 12 19 12H3C2.73478 12 2.48043 11.8946 2.29289 11.7071C2.10536 11.5196 2 11.2652 2 11V3C2 2.73478 2.10536 2.48043 2.29289 2.29289C2.48043 2.10536 2.73478 2 3 2H19C19.2652 2 19.5196 2.10536 19.7071 2.29289C19.8946 2.48043 20 2.73478 20 3V11ZM11 4C10.4067 4 9.82664 4.17595 9.33329 4.50559C8.83994 4.83524 8.45542 5.30377 8.22836 5.85195C8.0013 6.40013 7.94189 7.00333 8.05764 7.58527C8.1734 8.16721 8.45912 8.70176 8.87868 9.12132C9.29824 9.54088 9.83279 9.8266 10.4147 9.94236C10.9967 10.0581 11.5999 9.9987 12.1481 9.77164C12.6962 9.54458 13.1648 9.16006 13.4944 8.66671C13.8241 8.17336 14 7.59334 14 7C14 6.20435 13.6839 5.44129 13.1213 4.87868C12.5587 4.31607 11.7956 4 11 4ZM11 8C10.8022 8 10.6089 7.94135 10.4444 7.83147C10.28 7.72159 10.1518 7.56541 10.0761 7.38268C10.0004 7.19996 9.98063 6.99889 10.0192 6.80491C10.0578 6.61093 10.153 6.43275 10.2929 6.29289C10.4327 6.15304 10.6109 6.0578 10.8049 6.01921C10.9989 5.98063 11.2 6.00043 11.3827 6.07612C11.5654 6.15181 11.7216 6.27998 11.8315 6.44443C11.9414 6.60888 12 6.80222 12 7C12 7.26522 11.8946 7.51957 11.7071 7.70711C11.5196 7.89464 11.2652 8 11 8Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
36
frontend/src/mocks/billing-handlers.ts
Normal file
36
frontend/src/mocks/billing-handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const TEST_STRIPE_SECRET_KEY = "";
|
||||
const PRICE_ID = "";
|
||||
|
||||
export const STRIPE_BILLING_HANDLERS = [
|
||||
http.get("/api/billing/credits", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({ credits: "100" });
|
||||
}),
|
||||
|
||||
http.post("/api/billing/create-checkout-session", async ({ request }) => {
|
||||
await delay();
|
||||
const body = await request.json();
|
||||
|
||||
if (body && typeof body === "object" && body.amount) {
|
||||
const stripe = new Stripe(TEST_STRIPE_SECRET_KEY);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
line_items: [
|
||||
{
|
||||
price: PRICE_ID,
|
||||
quantity: body.amount,
|
||||
},
|
||||
],
|
||||
mode: "payment",
|
||||
success_url: "http://localhost:3001/settings/billing/?checkout=success",
|
||||
cancel_url: "http://localhost:3001/settings/billing/?checkout=cancel",
|
||||
});
|
||||
|
||||
if (session.url) return HttpResponse.json({ redirect_url: session.url });
|
||||
}
|
||||
|
||||
return HttpResponse.json({ message: "Invalid request" }, { status: 400 });
|
||||
}),
|
||||
];
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ResultSet,
|
||||
} from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
@@ -144,6 +145,7 @@ const openHandsHandlers = [
|
||||
];
|
||||
|
||||
export const handlers = [
|
||||
...STRIPE_BILLING_HANDLERS,
|
||||
...openHandsHandlers,
|
||||
http.get("/api/github/repositories", () =>
|
||||
HttpResponse.json([
|
||||
@@ -170,8 +172,9 @@ export const handlers = [
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
),
|
||||
http.get("/api/options/config", () => {
|
||||
const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
|
||||
const config: GetConfigResponse = {
|
||||
APP_MODE: "oss",
|
||||
APP_MODE: mockSaas ? "saas" : "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
};
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
export default [
|
||||
layout("routes/_oh/route.tsx", [
|
||||
index("routes/_oh._index/route.tsx"),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/account-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
]),
|
||||
route("conversations/:conversationId", "routes/_oh.app/route.tsx", [
|
||||
index("routes/_oh.app._index/route.tsx"),
|
||||
route("browser", "routes/_oh.app.browser.tsx"),
|
||||
|
||||
447
frontend/src/routes/account-settings.tsx
Normal file
447
frontend/src/routes/account-settings.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useAppLogout } from "#/hooks/use-app-logout";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
|
||||
const REMOTE_RUNTIME_OPTIONS = [
|
||||
{ key: 1, label: "1x (2 core, 8G)" },
|
||||
{ key: 2, label: "2x (4 core, 16G)" },
|
||||
];
|
||||
|
||||
function AccountSettings() {
|
||||
const {
|
||||
data: settings,
|
||||
isFetching: isFetchingSettings,
|
||||
isFetched,
|
||||
isSuccess: isSuccessfulSettings,
|
||||
} = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
const {
|
||||
data: resources,
|
||||
isFetching: isFetchingResources,
|
||||
isSuccess: isSuccessfulResources,
|
||||
} = useAIConfigOptions();
|
||||
const { mutate: saveSettings } = useSaveSettings();
|
||||
const { handleLogout } = useAppLogout();
|
||||
|
||||
const isFetching = isFetchingSettings || isFetchingResources;
|
||||
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
|
||||
|
||||
const determineWhetherToToggleAdvancedSettings = () => {
|
||||
if (isSuccess) {
|
||||
return (
|
||||
isCustomModel(resources.models, settings.LLM_MODEL) ||
|
||||
hasAdvancedSettingsSet(settings)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const hasAppSlug = !!config?.APP_SLUG;
|
||||
const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
|
||||
const isLLMKeySet = settings?.LLM_API_KEY === "**********";
|
||||
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
|
||||
|
||||
const modelsAndProviders = organizeModelsAndProviders(
|
||||
resources?.models || [],
|
||||
);
|
||||
|
||||
const [llmConfigMode, setLlmConfigMode] = React.useState<
|
||||
"basic" | "advanced"
|
||||
>(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
|
||||
React.useState(!!settings?.SECURITY_ANALYZER);
|
||||
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
const languageValue = AvailableLanguages.find(
|
||||
({ label }) => label === languageLabel,
|
||||
)?.value;
|
||||
|
||||
const llmProvider = formData.get("llm-provider-input")?.toString();
|
||||
const llmModel = formData.get("llm-model-input")?.toString();
|
||||
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
|
||||
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
|
||||
|
||||
const rawRemoteRuntimeResourceFactor = formData
|
||||
.get("runtime-settings-input")
|
||||
?.toString();
|
||||
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
|
||||
({ label }) => label === rawRemoteRuntimeResourceFactor,
|
||||
)?.key;
|
||||
|
||||
const userConsentsToAnalytics =
|
||||
formData.get("enable-analytics-switch")?.toString() === "on";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
github_token:
|
||||
formData.get("github-token-input")?.toString() || undefined,
|
||||
LANGUAGE: languageValue,
|
||||
user_consents_to_analytics: userConsentsToAnalytics,
|
||||
LLM_MODEL: customLlmModel || fullLlmModel,
|
||||
LLM_BASE_URL: formData.get("base-url-input")?.toString() || "",
|
||||
LLM_API_KEY:
|
||||
formData.get("llm-api-key-input")?.toString() ||
|
||||
(isLLMKeySet
|
||||
? undefined // don't update if it's already set
|
||||
: ""), // reset if it's first time save to avoid 500 error
|
||||
AGENT: formData.get("agent-input")?.toString(),
|
||||
SECURITY_ANALYZER:
|
||||
formData.get("security-analyzer-input")?.toString() || "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
remoteRuntimeResourceFactor ||
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
CONFIRMATION_MODE: confirmationModeIsEnabled,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(userConsentsToAnalytics);
|
||||
displaySuccessToast("Settings saved");
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
saveSettings(
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
LLM_API_KEY: "", // reset LLM API key
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
displaySuccessToast("Settings reset");
|
||||
setResetSettingsModalIsOpen(false);
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings is still loading by the time the state is set, it will always
|
||||
// default to basic settings. This is a workaround to ensure the correct
|
||||
// settings are displayed.
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
}, [isAdvancedSettingsSet]);
|
||||
|
||||
if (isFetched && !settings) {
|
||||
return <div>Failed to fetch settings. Please try reloading.</div>;
|
||||
}
|
||||
|
||||
const onToggleAdvancedMode = (isToggled: boolean) => {
|
||||
setLlmConfigMode(isToggled ? "advanced" : "basic");
|
||||
if (!isToggled) {
|
||||
// reset advanced state
|
||||
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching || !settings) {
|
||||
return (
|
||||
<div className="flex grow p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
ref={formRef}
|
||||
action={onSubmit}
|
||||
className="flex flex-col grow overflow-auto"
|
||||
>
|
||||
<div className="flex flex-col gap-12 px-11 py-9">
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-7">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
LLM Settings
|
||||
</h2>
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={isAdvancedSettingsSet}
|
||||
onToggle={onToggleAdvancedMode}
|
||||
>
|
||||
Advanced
|
||||
</SettingsSwitch>
|
||||
</div>
|
||||
|
||||
{llmConfigMode === "basic" && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label="Custom Model"
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label="Base URL"
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label="API Key"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
|
||||
}
|
||||
placeholder={isLLMKeySet ? "**********" : ""}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
/>
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label="Agent"
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSaas && llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label="Runtime Settings"
|
||||
items={REMOTE_RUNTIME_OPTIONS}
|
||||
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
|
||||
isDisabled
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
onToggle={setConfirmationModeIsEnabled}
|
||||
defaultIsToggled={!!settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
Enable confirmation mode
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
|
||||
<div>
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label="Security Analyzer"
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
GitHub Settings
|
||||
</h2>
|
||||
{isSaas && hasAppSlug && (
|
||||
<Link
|
||||
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
Configure GitHub Repositories
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
{!isSaas && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="github-token-input"
|
||||
name="github-token-input"
|
||||
label="GitHub Token"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isGitHubTokenSet && (
|
||||
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
|
||||
)
|
||||
}
|
||||
placeholder={isGitHubTokenSet ? "**********" : ""}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="github-token-help-anchor"
|
||||
text="Get your token"
|
||||
linkText="here"
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleLogout}
|
||||
isDisabled={!isGitHubTokenSet}
|
||||
>
|
||||
Disconnect from GitHub
|
||||
</BrandButton>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Additional Settings
|
||||
</h2>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="language-input"
|
||||
name="language-input"
|
||||
label="Language"
|
||||
items={AvailableLanguages.map((language) => ({
|
||||
key: language.value,
|
||||
label: language.label,
|
||||
}))}
|
||||
defaultSelectedKey={settings.LANGUAGE}
|
||||
isClearable={false}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!isAnalyticsEnabled}
|
||||
>
|
||||
Enable analytics
|
||||
</SettingsSwitch>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-[#454545]">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setResetSettingsModalIsOpen(true)}
|
||||
>
|
||||
Reset to defaults
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
formRef.current?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</BrandButton>
|
||||
</footer>
|
||||
|
||||
{resetSettingsModalIsOpen && (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="reset-modal"
|
||||
className="bg-root-primary p-4 rounded-xl flex flex-col gap-4"
|
||||
>
|
||||
<p>Are you sure you want to reset all settings?</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
setResetSettingsModalIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettings;
|
||||
39
frontend/src/routes/billing.tsx
Normal file
39
frontend/src/routes/billing.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect, useSearchParams } from "react-router";
|
||||
import React from "react";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { queryClient } from "#/entry.client";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { BILLING_SETTINGS } from "#/utils/feature-flags";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const config = queryClient.getQueryData<GetConfigResponse>(["config"]);
|
||||
|
||||
if (config?.APP_MODE !== "saas" || !BILLING_SETTINGS()) {
|
||||
return redirect("/settings");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function BillingSettingsScreen() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const checkoutStatus = searchParams.get("checkout");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (checkoutStatus === "success") {
|
||||
displaySuccessToast("Payment successful");
|
||||
} else if (checkoutStatus === "cancel") {
|
||||
displayErrorToast("Payment cancelled");
|
||||
}
|
||||
|
||||
setSearchParams({});
|
||||
}, [checkoutStatus]);
|
||||
|
||||
return <PaymentForm />;
|
||||
}
|
||||
|
||||
export default BillingSettingsScreen;
|
||||
@@ -1,462 +1,52 @@
|
||||
import React from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { useAppLogout } from "#/hooks/use-app-logout";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
|
||||
const REMOTE_RUNTIME_OPTIONS = [
|
||||
{ key: 1, label: "1x (2 core, 8G)" },
|
||||
{ key: 2, label: "2x (4 core, 16G)" },
|
||||
];
|
||||
|
||||
const displayErrorToast = (error: string) => {
|
||||
toast.error(error, {
|
||||
position: "top-right",
|
||||
style: {
|
||||
background: "#454545",
|
||||
border: "1px solid #717888",
|
||||
color: "#fff",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const displaySuccessToast = (message: string) => {
|
||||
toast.success(message, {
|
||||
position: "top-right",
|
||||
style: {
|
||||
background: "#454545",
|
||||
border: "1px solid #717888",
|
||||
color: "#fff",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
});
|
||||
};
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { BILLING_SETTINGS } from "#/utils/feature-flags";
|
||||
|
||||
function SettingsScreen() {
|
||||
const {
|
||||
data: settings,
|
||||
isFetching: isFetchingSettings,
|
||||
isFetched,
|
||||
isSuccess: isSuccessfulSettings,
|
||||
} = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
const {
|
||||
data: resources,
|
||||
isFetching: isFetchingResources,
|
||||
isSuccess: isSuccessfulResources,
|
||||
} = useAIConfigOptions();
|
||||
const { mutate: saveSettings } = useSaveSettings();
|
||||
const { handleLogout } = useAppLogout();
|
||||
|
||||
const isFetching = isFetchingSettings || isFetchingResources;
|
||||
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
|
||||
|
||||
const determineWhetherToToggleAdvancedSettings = () => {
|
||||
if (isSuccess) {
|
||||
return (
|
||||
isCustomModel(resources.models, settings.LLM_MODEL) ||
|
||||
hasAdvancedSettingsSet(settings)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const hasAppSlug = !!config?.APP_SLUG;
|
||||
const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET;
|
||||
const isLLMKeySet = settings?.LLM_API_KEY === "**********";
|
||||
const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings();
|
||||
|
||||
const modelsAndProviders = organizeModelsAndProviders(
|
||||
resources?.models || [],
|
||||
);
|
||||
|
||||
const [llmConfigMode, setLlmConfigMode] = React.useState<
|
||||
"basic" | "advanced"
|
||||
>(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
|
||||
React.useState(!!settings?.SECURITY_ANALYZER);
|
||||
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const formAction = async (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
const languageValue = AvailableLanguages.find(
|
||||
({ label }) => label === languageLabel,
|
||||
)?.value;
|
||||
|
||||
const llmProvider = formData.get("llm-provider-input")?.toString();
|
||||
const llmModel = formData.get("llm-model-input")?.toString();
|
||||
const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase();
|
||||
const customLlmModel = formData.get("llm-custom-model-input")?.toString();
|
||||
|
||||
const rawRemoteRuntimeResourceFactor = formData
|
||||
.get("runtime-settings-input")
|
||||
?.toString();
|
||||
const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find(
|
||||
({ label }) => label === rawRemoteRuntimeResourceFactor,
|
||||
)?.key;
|
||||
|
||||
const userConsentsToAnalytics =
|
||||
formData.get("enable-analytics-switch")?.toString() === "on";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
github_token:
|
||||
formData.get("github-token-input")?.toString() || undefined,
|
||||
LANGUAGE: languageValue,
|
||||
user_consents_to_analytics: userConsentsToAnalytics,
|
||||
LLM_MODEL: customLlmModel || fullLlmModel,
|
||||
LLM_BASE_URL: formData.get("base-url-input")?.toString() || "",
|
||||
LLM_API_KEY:
|
||||
formData.get("llm-api-key-input")?.toString() ||
|
||||
(isLLMKeySet
|
||||
? undefined // don't update if it's already set
|
||||
: ""), // reset if it's first time save to avoid 500 error
|
||||
AGENT: formData.get("agent-input")?.toString(),
|
||||
SECURITY_ANALYZER:
|
||||
formData.get("security-analyzer-input")?.toString() || "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
remoteRuntimeResourceFactor ||
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
CONFIRMATION_MODE: confirmationModeIsEnabled,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(userConsentsToAnalytics);
|
||||
displaySuccessToast("Settings saved");
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
saveSettings(
|
||||
{
|
||||
...DEFAULT_SETTINGS,
|
||||
LLM_API_KEY: "", // reset LLM API key
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
displaySuccessToast("Settings reset");
|
||||
setResetSettingsModalIsOpen(false);
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings is still loading by the time the state is set, it will always
|
||||
// default to basic settings. This is a workaround to ensure the correct
|
||||
// settings are displayed.
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
}, [isAdvancedSettingsSet]);
|
||||
|
||||
if (isFetched && !settings) {
|
||||
return <div>Failed to fetch settings. Please try reloading.</div>;
|
||||
}
|
||||
|
||||
const onToggleAdvancedMode = (isToggled: boolean) => {
|
||||
setLlmConfigMode(isToggled ? "advanced" : "basic");
|
||||
if (!isToggled) {
|
||||
// reset advanced state
|
||||
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
className="bg-[#262626] border border-[#454545] h-full rounded-xl"
|
||||
className="bg-[#24272E] border border-[#454545] h-full rounded-xl flex flex-col"
|
||||
>
|
||||
<form action={formAction} className="flex flex-col h-full">
|
||||
<header className="px-3 py-1.5 border-b border-b-[#454545] flex items-center gap-2">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<h1 className="text-sm leading-6">Settings</h1>
|
||||
</header>
|
||||
<header className="px-3 py-1.5 border-b border-b-[#454545] flex items-center gap-2">
|
||||
<SettingsIcon width={16} height={16} />
|
||||
<h1 className="text-sm leading-6">Settings</h1>
|
||||
</header>
|
||||
|
||||
{isFetching && (
|
||||
<div className="flex grow p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && settings && (
|
||||
<div className="flex flex-col gap-12 grow overflow-y-auto px-11 py-9">
|
||||
<section className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-7">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
LLM Settings
|
||||
</h2>
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={isAdvancedSettingsSet}
|
||||
onToggle={onToggleAdvancedMode}
|
||||
>
|
||||
Advanced
|
||||
</SettingsSwitch>
|
||||
</div>
|
||||
|
||||
{llmConfigMode === "basic" && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label="Custom Model"
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label="Base URL"
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label="API Key"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
|
||||
}
|
||||
placeholder={isLLMKeySet ? "**********" : ""}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text="Don't know your API key?"
|
||||
linkText="Click here for instructions"
|
||||
href="https://docs.all-hands.dev/modules/usage/llms"
|
||||
/>
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label="Agent"
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSaas && llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label="Runtime Settings"
|
||||
items={REMOTE_RUNTIME_OPTIONS}
|
||||
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
|
||||
isDisabled
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
onToggle={setConfirmationModeIsEnabled}
|
||||
defaultIsToggled={!!settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
Enable confirmation mode
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
|
||||
<div>
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label="Security Analyzer"
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
GitHub Settings
|
||||
</h2>
|
||||
{isSaas && hasAppSlug && (
|
||||
<Link
|
||||
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
Configure GitHub Repositories
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
{!isSaas && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="github-token-input"
|
||||
name="github-token-input"
|
||||
label="GitHub Token"
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isGitHubTokenSet && (
|
||||
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
|
||||
)
|
||||
}
|
||||
placeholder={isGitHubTokenSet ? "**********" : ""}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="github-token-help-anchor"
|
||||
text="Get your token"
|
||||
linkText="here"
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleLogout}
|
||||
isDisabled={!isGitHubTokenSet}
|
||||
>
|
||||
Disconnect from GitHub
|
||||
</BrandButton>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
Additional Settings
|
||||
</h2>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="language-input"
|
||||
name="language-input"
|
||||
label="Language"
|
||||
items={AvailableLanguages.map((language) => ({
|
||||
key: language.value,
|
||||
label: language.label,
|
||||
}))}
|
||||
defaultSelectedKey={settings.LANGUAGE}
|
||||
isClearable={false}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!isAnalyticsEnabled}
|
||||
>
|
||||
Enable analytics
|
||||
</SettingsSwitch>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-[#454545]">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setResetSettingsModalIsOpen(true)}
|
||||
>
|
||||
Reset to defaults
|
||||
</BrandButton>
|
||||
<BrandButton type="submit" variant="primary">
|
||||
Save Changes
|
||||
</BrandButton>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
{resetSettingsModalIsOpen && (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="reset-modal"
|
||||
className="bg-root-primary p-4 rounded-xl flex flex-col gap-4"
|
||||
>
|
||||
<p>Are you sure you want to reset all settings?</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
setResetSettingsModalIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
{isSaas && BILLING_SETTINGS() && (
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-12 px-11 border-b border-[#454545]"
|
||||
>
|
||||
{[
|
||||
{ to: "/settings", text: "Account" },
|
||||
{ to: "/settings/billing", text: "Credits" },
|
||||
].map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"border-b-2 border-transparent py-2.5",
|
||||
isActive && "border-[#C9B974]",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col grow overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
10
frontend/src/utils/amount-is-valid.ts
Normal file
10
frontend/src/utils/amount-is-valid.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const MINIMUM_AMOUNT = 25;
|
||||
|
||||
export const amountIsValid = (amount: string) => {
|
||||
const float = parseFloat(amount);
|
||||
if (Number.isNaN(float)) return false;
|
||||
if (float < 0) return false;
|
||||
if (float < MINIMUM_AMOUNT) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
25
frontend/src/utils/custom-toast-handlers.ts
Normal file
25
frontend/src/utils/custom-toast-handlers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const displayErrorToast = (error: string) => {
|
||||
toast.error(error, {
|
||||
position: "top-right",
|
||||
style: {
|
||||
background: "#454545",
|
||||
border: "1px solid #717888",
|
||||
color: "#fff",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const displaySuccessToast = (message: string) => {
|
||||
toast.success(message, {
|
||||
position: "top-right",
|
||||
style: {
|
||||
background: "#454545",
|
||||
border: "1px solid #717888",
|
||||
color: "#fff",
|
||||
borderRadius: "4px",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -13,3 +13,4 @@ function loadFeatureFlag(
|
||||
}
|
||||
|
||||
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");
|
||||
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
|
||||
|
||||
@@ -1,69 +1,17 @@
|
||||
import test, { expect, Page } from "@playwright/test";
|
||||
import test, { expect } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
test("do not navigate to /settings/billing if not SaaS mode", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/billing");
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible();
|
||||
expect(page.url()).toBe("http://localhost:3001/settings");
|
||||
});
|
||||
|
||||
const selectGpt4o = async (page: Page) => {
|
||||
const aiConfigModal = page.getByTestId("ai-config-modal");
|
||||
await expect(aiConfigModal).toBeVisible();
|
||||
|
||||
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
|
||||
await providerSelectElement.click();
|
||||
|
||||
const openAiOption = page.getByTestId("provider-item-openai");
|
||||
await openAiOption.click();
|
||||
|
||||
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
|
||||
await modelSelectElement.click();
|
||||
|
||||
const gpt4Option = page.getByText("gpt-4o", { exact: true });
|
||||
await gpt4Option.click();
|
||||
|
||||
return {
|
||||
aiConfigModal,
|
||||
providerSelectElement,
|
||||
modelSelectElement,
|
||||
};
|
||||
};
|
||||
|
||||
test("change ai config settings", async ({ page }) => {
|
||||
const { aiConfigModal, modelSelectElement, providerSelectElement } =
|
||||
await selectGpt4o(page);
|
||||
|
||||
const saveButton = aiConfigModal.getByText("Save");
|
||||
await saveButton.click();
|
||||
|
||||
const settingsButton = page.getByTestId("settings-button");
|
||||
await settingsButton.click();
|
||||
|
||||
await expect(providerSelectElement).toHaveValue("OpenAI");
|
||||
await expect(modelSelectElement).toHaveValue("gpt-4o");
|
||||
});
|
||||
|
||||
test("reset to default settings", async ({ page }) => {
|
||||
const { aiConfigModal } = await selectGpt4o(page);
|
||||
|
||||
const saveButton = aiConfigModal.getByText("Save");
|
||||
await saveButton.click();
|
||||
|
||||
const settingsButton = page.getByTestId("settings-button");
|
||||
await settingsButton.click();
|
||||
|
||||
const resetButton = aiConfigModal.getByText(/reset to defaults/i);
|
||||
await resetButton.click();
|
||||
|
||||
const endSessionModal = page.getByTestId("reset-defaults-modal");
|
||||
expect(endSessionModal).toBeVisible();
|
||||
|
||||
const confirmButton = endSessionModal.getByText(/reset to defaults/i);
|
||||
await confirmButton.click();
|
||||
|
||||
await settingsButton.click();
|
||||
|
||||
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
|
||||
await expect(providerSelectElement).toHaveValue("Anthropic");
|
||||
|
||||
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
|
||||
await expect(modelSelectElement).toHaveValue(/claude-3.5/i);
|
||||
// FIXME: This test is failing because the config is not being set to SaaS mode
|
||||
// since MSW is always returning APP_MODE as "oss"
|
||||
test.skip("navigate to /settings/billing if SaaS mode", async ({ page }) => {
|
||||
await page.goto("/settings/billing");
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible();
|
||||
expect(page.url()).toBe("http://localhost:3001/settings/billing");
|
||||
});
|
||||
|
||||
3043
poetry.lock
generated
3043
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,7 @@ openhands-aci = "^0.2.3"
|
||||
python-socketio = "^5.11.4"
|
||||
redis = "^5.2.0"
|
||||
sse-starlette = "^2.1.3"
|
||||
stripe = "^11.5.0"
|
||||
ipywidgets = "^8.1.5"
|
||||
qtconsole = "^5.6.1"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user