feat(SaaS): Billing settings screen (#6495)

Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
This commit is contained in:
sp.wack
2025-02-18 21:56:10 +04:00
committed by GitHub
parent e3e00ed70a
commit 2e98fc8fb3
26 changed files with 2654 additions and 2019 deletions

View File

@@ -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();
});
});
});

View File

@@ -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");
});
});

View 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");
});
});

View File

@@ -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" }],
},
]);

View 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);
});
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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");

View 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>
);
}

View File

@@ -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",
)}
/>

View File

@@ -46,7 +46,7 @@ async function prepareApp() {
}
}
const queryClient = new QueryClient(queryClientConfig);
export const queryClient = new QueryClient(queryClientConfig);
prepareApp().then(() =>
startTransition(() => {

View File

@@ -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;
},
});

View 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",
});
};

View 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

View 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 });
}),
];

View File

@@ -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",
};

View File

@@ -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"),

View 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;

View 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;

View File

@@ -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>
);
}

View 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;
};

View 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",
},
});
};

View File

@@ -13,3 +13,4 @@ function loadFeatureFlag(
}
export const MEMORY_CONDENSER = loadFeatureFlag("MEMORY_CONDENSER");
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"