From fa559ace86cf0331eb24e4061b8e5fe3f5a9a995 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 23 Apr 2025 11:08:32 -0400 Subject: [PATCH] Add API keys management UI to settings page (#7710) Co-authored-by: openhands --- .../routes/settings-with-payment.test.tsx | 10 +- frontend/src/api/api-keys.ts | 49 ++++++ .../features/payment/payment-form.tsx | 6 +- .../features/settings/api-key-modal-base.tsx | 33 ++++ .../features/settings/api-keys-manager.tsx | 146 ++++++++++++++++++ .../features/settings/brand-button.tsx | 3 +- .../settings/create-api-key-modal.tsx | 101 ++++++++++++ .../settings/delete-api-key-modal.tsx | 84 ++++++++++ .../features/settings/new-api-key-modal.tsx | 61 ++++++++ .../features/settings/settings-input.tsx | 5 +- .../src/hooks/mutation/use-create-api-key.ts | 16 ++ .../src/hooks/mutation/use-delete-api-key.ts | 17 ++ frontend/src/hooks/query/use-api-keys.ts | 22 +++ frontend/src/i18n/declaration.ts | 21 +++ frontend/src/i18n/translation.json | 63 ++++++++ frontend/src/routes.ts | 1 + frontend/src/routes/api-keys.tsx | 12 ++ frontend/src/routes/billing.tsx | 14 +- frontend/src/routes/settings.tsx | 4 +- 19 files changed, 642 insertions(+), 26 deletions(-) create mode 100644 frontend/src/api/api-keys.ts create mode 100644 frontend/src/components/features/settings/api-key-modal-base.tsx create mode 100644 frontend/src/components/features/settings/api-keys-manager.tsx create mode 100644 frontend/src/components/features/settings/create-api-key-modal.tsx create mode 100644 frontend/src/components/features/settings/delete-api-key-modal.tsx create mode 100644 frontend/src/components/features/settings/new-api-key-modal.tsx create mode 100644 frontend/src/hooks/mutation/use-create-api-key.ts create mode 100644 frontend/src/hooks/mutation/use-delete-api-key.ts create mode 100644 frontend/src/hooks/query/use-api-keys.ts create mode 100644 frontend/src/routes/api-keys.tsx diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index 83ddc95848..d86d31e85b 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -43,10 +43,12 @@ describe("Settings Billing", () => { renderSettingsScreen(); - await waitFor(() => { - const navbar = screen.queryByTestId("settings-navbar"); - expect(navbar).not.toBeInTheDocument(); - }); + // Wait for the settings screen to be rendered + await screen.findByTestId("settings-screen"); + + // Then check that the navbar is not present + const navbar = screen.queryByTestId("settings-navbar"); + expect(navbar).not.toBeInTheDocument(); }); it("should render the navbar if SaaS mode", async () => { diff --git a/frontend/src/api/api-keys.ts b/frontend/src/api/api-keys.ts new file mode 100644 index 0000000000..cfcad2fede --- /dev/null +++ b/frontend/src/api/api-keys.ts @@ -0,0 +1,49 @@ +import { openHands } from "./open-hands-axios"; + +export interface ApiKey { + id: string; + name: string; + prefix: string; + created_at: string; + last_used_at: string | null; +} + +export interface CreateApiKeyResponse { + id: string; + name: string; + key: string; // Full key, only returned once upon creation + prefix: string; + created_at: string; +} + +class ApiKeysClient { + /** + * Get all API keys for the current user + */ + static async getApiKeys(): Promise { + const { data } = await openHands.get("/api/keys"); + // Ensure we always return an array, even if the API returns something else + return Array.isArray(data) ? (data as ApiKey[]) : []; + } + + /** + * Create a new API key + * @param name - A descriptive name for the API key + */ + static async createApiKey(name: string): Promise { + const { data } = await openHands.post("/api/keys", { + name, + }); + return data; + } + + /** + * Delete an API key + * @param id - The ID of the API key to delete + */ + static async deleteApiKey(id: string): Promise { + await openHands.delete(`/api/keys/${id}`); + } +} + +export default ApiKeysClient; diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx index 1c8b0a6417..bc337c05df 100644 --- a/frontend/src/components/features/payment/payment-form.tsx +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -40,10 +40,6 @@ export function PaymentForm() { data-testid="billing-settings" className="flex flex-col gap-6 px-11 py-9" > -

- {t(I18nKey.PAYMENT$MANAGE_CREDITS)} -

-
- Balance + {t(I18nKey.PAYMENT$MANAGE_CREDITS)}
{!isLoading && ( ${Number(balance).toFixed(2)} diff --git a/frontend/src/components/features/settings/api-key-modal-base.tsx b/frontend/src/components/features/settings/api-key-modal-base.tsx new file mode 100644 index 0000000000..43d8ba89f0 --- /dev/null +++ b/frontend/src/components/features/settings/api-key-modal-base.tsx @@ -0,0 +1,33 @@ +import React, { ReactNode } from "react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; + +interface ApiKeyModalBaseProps { + isOpen: boolean; + title: string; + width?: string; + children: ReactNode; + footer: ReactNode; +} + +export function ApiKeyModalBase({ + isOpen, + title, + width = "500px", + children, + footer, +}: ApiKeyModalBaseProps) { + if (!isOpen) return null; + + return ( + +
+

{title}

+ {children} +
{footer}
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/api-keys-manager.tsx b/frontend/src/components/features/settings/api-keys-manager.tsx new file mode 100644 index 0000000000..2490d4bce0 --- /dev/null +++ b/frontend/src/components/features/settings/api-keys-manager.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { CreateApiKeyModal } from "./create-api-key-modal"; +import { DeleteApiKeyModal } from "./delete-api-key-modal"; +import { NewApiKeyModal } from "./new-api-key-modal"; +import { useApiKeys } from "#/hooks/query/use-api-keys"; + +export function ApiKeysManager() { + const { t } = useTranslation(); + const { data: apiKeys = [], isLoading, error } = useApiKeys(); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [keyToDelete, setKeyToDelete] = useState(null); + const [newlyCreatedKey, setNewlyCreatedKey] = + useState(null); + const [showNewKeyModal, setShowNewKeyModal] = useState(false); + + // Display error toast if the query fails + if (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + + const handleKeyCreated = (newKey: CreateApiKeyResponse) => { + setNewlyCreatedKey(newKey); + setCreateModalOpen(false); + setShowNewKeyModal(true); + }; + + const handleCloseCreateModal = () => { + setCreateModalOpen(false); + }; + + const handleCloseDeleteModal = () => { + setDeleteModalOpen(false); + setKeyToDelete(null); + }; + + const handleCloseNewKeyModal = () => { + setShowNewKeyModal(false); + setNewlyCreatedKey(null); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return "Never"; + return new Date(dateString).toLocaleString(); + }; + + return ( + <> +
+
+ setCreateModalOpen(true)} + > + {t(I18nKey.SETTINGS$CREATE_API_KEY)} + +
+ +

+ {t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)} +

+ + {isLoading && ( +
+ +
+ )} + {!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && ( +
+ + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + ))} + +
+ {t(I18nKey.SETTINGS$NAME)} + + {t(I18nKey.SETTINGS$CREATED_AT)} + + {t(I18nKey.SETTINGS$LAST_USED)} + + {t(I18nKey.SETTINGS$ACTIONS)} +
{key.name} + {formatDate(key.created_at)} + + {formatDate(key.last_used_at)} + + +
+
+ )} +
+ + {/* Create API Key Modal */} + + + {/* Delete API Key Modal */} + + + {/* Show New API Key Modal */} + + + ); +} diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx index 03210f46e9..e13a2aa2d5 100644 --- a/frontend/src/components/features/settings/brand-button.tsx +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -2,7 +2,7 @@ import { cn } from "#/utils/utils"; interface BrandButtonProps { testId?: string; - variant: "primary" | "secondary"; + variant: "primary" | "secondary" | "danger"; type: React.ButtonHTMLAttributes["type"]; isDisabled?: boolean; className?: string; @@ -32,6 +32,7 @@ export function BrandButton({ "w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80", variant === "primary" && "bg-primary text-[#0D0F11]", variant === "secondary" && "border border-primary text-primary", + variant === "danger" && "bg-red-600 text-white hover:bg-red-700", startContent && "flex items-center justify-center gap-2", className, )} diff --git a/frontend/src/components/features/settings/create-api-key-modal.tsx b/frontend/src/components/features/settings/create-api-key-modal.tsx new file mode 100644 index 0000000000..b97d29f349 --- /dev/null +++ b/frontend/src/components/features/settings/create-api-key-modal.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { CreateApiKeyResponse } from "#/api/api-keys"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; +import { useCreateApiKey } from "#/hooks/mutation/use-create-api-key"; + +interface CreateApiKeyModalProps { + isOpen: boolean; + onClose: () => void; + onKeyCreated: (newKey: CreateApiKeyResponse) => void; +} + +export function CreateApiKeyModal({ + isOpen, + onClose, + onKeyCreated, +}: CreateApiKeyModalProps) { + const { t } = useTranslation(); + const [newKeyName, setNewKeyName] = useState(""); + + const createApiKeyMutation = useCreateApiKey(); + + const handleCreateKey = async () => { + if (!newKeyName.trim()) { + displayErrorToast(t(I18nKey.ERROR$REQUIRED_FIELD)); + return; + } + + try { + const newKey = await createApiKeyMutation.mutateAsync(newKeyName); + onKeyCreated(newKey); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED)); + setNewKeyName(""); + } catch (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + const handleCancel = () => { + setNewKeyName(""); + onClose(); + }; + + const modalFooter = ( + <> + + {createApiKeyMutation.isPending ? ( + + ) : ( + t(I18nKey.BUTTON$CREATE) + )} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ); + + return ( + +
+

+ {t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)} +

+ setNewKeyName(value)} + className="w-full mt-4" + type="text" + /> +
+
+ ); +} diff --git a/frontend/src/components/features/settings/delete-api-key-modal.tsx b/frontend/src/components/features/settings/delete-api-key-modal.tsx new file mode 100644 index 0000000000..1875077458 --- /dev/null +++ b/frontend/src/components/features/settings/delete-api-key-modal.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKey } from "#/api/api-keys"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; +import { useDeleteApiKey } from "#/hooks/mutation/use-delete-api-key"; + +interface DeleteApiKeyModalProps { + isOpen: boolean; + keyToDelete: ApiKey | null; + onClose: () => void; +} + +export function DeleteApiKeyModal({ + isOpen, + keyToDelete, + onClose, +}: DeleteApiKeyModalProps) { + const { t } = useTranslation(); + const deleteApiKeyMutation = useDeleteApiKey(); + + const handleDeleteKey = async () => { + if (!keyToDelete) return; + + try { + await deleteApiKeyMutation.mutateAsync(keyToDelete.id); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED)); + onClose(); + } catch (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + if (!keyToDelete) return null; + + const modalFooter = ( + <> + + {deleteApiKeyMutation.isPending ? ( + + ) : ( + t(I18nKey.BUTTON$DELETE) + )} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ); + + return ( + +
+

+ {t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, { + name: keyToDelete.name, + })} +

+
+
+ ); +} diff --git a/frontend/src/components/features/settings/new-api-key-modal.tsx b/frontend/src/components/features/settings/new-api-key-modal.tsx new file mode 100644 index 0000000000..2457f6a46e --- /dev/null +++ b/frontend/src/components/features/settings/new-api-key-modal.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { CreateApiKeyResponse } from "#/api/api-keys"; +import { displaySuccessToast } from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; + +interface NewApiKeyModalProps { + isOpen: boolean; + newlyCreatedKey: CreateApiKeyResponse | null; + onClose: () => void; +} + +export function NewApiKeyModal({ + isOpen, + newlyCreatedKey, + onClose, +}: NewApiKeyModalProps) { + const { t } = useTranslation(); + + const handleCopyToClipboard = () => { + if (newlyCreatedKey) { + navigator.clipboard.writeText(newlyCreatedKey.key); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED)); + } + }; + + if (!newlyCreatedKey) return null; + + const modalFooter = ( + <> + + {t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)} + + + {t(I18nKey.BUTTON$CLOSE)} + + + ); + + return ( + +
+

{t(I18nKey.SETTINGS$API_KEY_WARNING)}

+
+ {newlyCreatedKey.key} +
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx index 75bec0c9c1..4aad109f24 100644 --- a/frontend/src/components/features/settings/settings-input.tsx +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -7,6 +7,7 @@ interface SettingsInputProps { label: string; type: React.HTMLInputTypeAttribute; defaultValue?: string; + value?: string; placeholder?: string; showOptionalTag?: boolean; isDisabled?: boolean; @@ -24,6 +25,7 @@ export function SettingsInput({ label, type, defaultValue, + value, placeholder, showOptionalTag, isDisabled, @@ -43,11 +45,12 @@ export function SettingsInput({
onChange?.(e.target.value)} + onChange={(e) => onChange && onChange(e.target.value)} name={name} disabled={isDisabled} type={type} defaultValue={defaultValue} + value={value} placeholder={placeholder} min={min} max={max} diff --git a/frontend/src/hooks/mutation/use-create-api-key.ts b/frontend/src/hooks/mutation/use-create-api-key.ts new file mode 100644 index 0000000000..fd3c05c975 --- /dev/null +++ b/frontend/src/hooks/mutation/use-create-api-key.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys"; +import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; + +export function useCreateApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (name: string): Promise => + ApiKeysClient.createApiKey(name), + onSuccess: () => { + // Invalidate the API keys query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/mutation/use-delete-api-key.ts b/frontend/src/hooks/mutation/use-delete-api-key.ts new file mode 100644 index 0000000000..4f4b566fab --- /dev/null +++ b/frontend/src/hooks/mutation/use-delete-api-key.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiKeysClient from "#/api/api-keys"; +import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; + +export function useDeleteApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string): Promise => { + await ApiKeysClient.deleteApiKey(id); + }, + onSuccess: () => { + // Invalidate the API keys query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts new file mode 100644 index 0000000000..8549d44171 --- /dev/null +++ b/frontend/src/hooks/query/use-api-keys.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiKeysClient from "#/api/api-keys"; +import { useConfig } from "./use-config"; +import { useAuth } from "#/context/auth-context"; + +export const API_KEYS_QUERY_KEY = "api-keys"; + +export function useApiKeys() { + const { providersAreSet } = useAuth(); + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [API_KEYS_QUERY_KEY], + enabled: providersAreSet && config?.APP_MODE === "saas", + queryFn: async () => { + const keys = await ApiKeysClient.getApiKeys(); + return Array.isArray(keys) ? keys : []; + }, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 2b103cca86..1128f99eff 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -267,6 +267,27 @@ export enum I18nKey { SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS", SETTINGS$SAVED = "SETTINGS$SAVED", SETTINGS$RESET = "SETTINGS$RESET", + SETTINGS$API_KEYS = "SETTINGS$API_KEYS", + SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION", + SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY", + SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION", + SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY", + SETTINGS$DELETE_API_KEY_CONFIRMATION = "SETTINGS$DELETE_API_KEY_CONFIRMATION", + SETTINGS$NO_API_KEYS = "SETTINGS$NO_API_KEYS", + SETTINGS$NAME = "SETTINGS$NAME", + SETTINGS$KEY_PREFIX = "SETTINGS$KEY_PREFIX", + SETTINGS$CREATED_AT = "SETTINGS$CREATED_AT", + SETTINGS$LAST_USED = "SETTINGS$LAST_USED", + SETTINGS$ACTIONS = "SETTINGS$ACTIONS", + SETTINGS$API_KEY_CREATED = "SETTINGS$API_KEY_CREATED", + SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED", + SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING", + SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED", + SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER", + BUTTON$CREATE = "BUTTON$CREATE", + BUTTON$DELETE = "BUTTON$DELETE", + BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD", + ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD", PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE", FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL", FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 17c03f4136..876572504b 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -3983,6 +3983,69 @@ "tr": "Ayarlar sıfırlandı", "de": "Einstellungen zurückgesetzt" }, + "SETTINGS$API_KEYS": { + "en": "API Keys" + }, + "SETTINGS$API_KEYS_DESCRIPTION": { + "en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account." + }, + "SETTINGS$CREATE_API_KEY": { + "en": "Create API Key" + }, + "SETTINGS$CREATE_API_KEY_DESCRIPTION": { + "en": "Give your API key a descriptive name to help you identify it later." + }, + "SETTINGS$DELETE_API_KEY": { + "en": "Delete API Key" + }, + "SETTINGS$DELETE_API_KEY_CONFIRMATION": { + "en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone." + }, + "SETTINGS$NO_API_KEYS": { + "en": "You don't have any API keys yet. Create one to get started." + }, + "SETTINGS$NAME": { + "en": "Name" + }, + "SETTINGS$KEY_PREFIX": { + "en": "Key Prefix" + }, + "SETTINGS$CREATED_AT": { + "en": "Created" + }, + "SETTINGS$LAST_USED": { + "en": "Last Used" + }, + "SETTINGS$ACTIONS": { + "en": "Actions" + }, + "SETTINGS$API_KEY_CREATED": { + "en": "API Key Created" + }, + "SETTINGS$API_KEY_DELETED": { + "en": "API key deleted successfully" + }, + "SETTINGS$API_KEY_WARNING": { + "en": "This is the only time your API key will be displayed. Please copy it now and store it securely." + }, + "SETTINGS$API_KEY_COPIED": { + "en": "API key copied to clipboard" + }, + "SETTINGS$API_KEY_NAME_PLACEHOLDER": { + "en": "My API Key" + }, + "BUTTON$CREATE": { + "en": "Create" + }, + "BUTTON$DELETE": { + "en": "Delete" + }, + "BUTTON$COPY_TO_CLIPBOARD": { + "en": "Copy to Clipboard" + }, + "ERROR$REQUIRED_FIELD": { + "en": "This field is required" + }, "PLANNER$EMPTY_MESSAGE": { "en": "No plan created.", "zh-CN": "计划未创建", diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 9c8f1e4bd8..3543a2f65e 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -11,6 +11,7 @@ export default [ route("settings", "routes/settings.tsx", [ index("routes/account-settings.tsx"), route("billing", "routes/billing.tsx"), + route("api-keys", "routes/api-keys.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx", [ index("routes/editor.tsx"), diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx new file mode 100644 index 0000000000..b1609c75f9 --- /dev/null +++ b/frontend/src/routes/api-keys.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"; + +function ApiKeysScreen() { + return ( +
+ +
+ ); +} + +export default ApiKeysScreen; diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index 7d1a4ead2e..fdd410f6c4 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -1,25 +1,13 @@ -import { redirect, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import React from "react"; import { useTranslation } from "react-i18next"; 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 { I18nKey } from "#/i18n/declaration"; -export const clientLoader = async () => { - const config = queryClient.getQueryData(["config"]); - - if (config?.APP_MODE !== "saas" || !config.FEATURE_FLAGS.ENABLE_BILLING) { - return redirect("/settings"); - } - - return null; -}; - function BillingSettingsScreen() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index b89fa2db86..686e422c7f 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -9,7 +9,6 @@ function SettingsScreen() { const { t } = useTranslation(); const { data: config } = useConfig(); const isSaas = config?.APP_MODE === "saas"; - const billingIsEnabled = config?.FEATURE_FLAGS.ENABLE_BILLING; return (
{t(I18nKey.SETTINGS$TITLE)} - {isSaas && billingIsEnabled && ( + {isSaas && (