mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add API keys management UI to settings page (#7710)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
1fd26d196a
commit
fa559ace86
@ -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 () => {
|
||||
|
||||
49
frontend/src/api/api-keys.ts
Normal file
49
frontend/src/api/api-keys.ts
Normal file
@ -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<ApiKey[]> {
|
||||
const { data } = await openHands.get<unknown>("/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<CreateApiKeyResponse> {
|
||||
const { data } = await openHands.post<CreateApiKeyResponse>("/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<void> {
|
||||
await openHands.delete(`/api/keys/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiKeysClient;
|
||||
@ -40,10 +40,6 @@ export function PaymentForm() {
|
||||
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">
|
||||
{t(I18nKey.PAYMENT$MANAGE_CREDITS)}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
|
||||
@ -52,7 +48,7 @@ export function PaymentForm() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MoneyIcon width={22} height={14} />
|
||||
<span>Balance</span>
|
||||
<span>{t(I18nKey.PAYMENT$MANAGE_CREDITS)}</span>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
|
||||
|
||||
@ -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 (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
className="bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-tertiary"
|
||||
style={{ width }}
|
||||
>
|
||||
<h3 className="text-xl font-bold">{title}</h3>
|
||||
{children}
|
||||
<div className="w-full flex gap-2 mt-2">{footer}</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/features/settings/api-keys-manager.tsx
Normal file
146
frontend/src/components/features/settings/api-keys-manager.tsx
Normal file
@ -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<ApiKey | null>(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] =
|
||||
useState<CreateApiKeyResponse | null>(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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CREATE_API_KEY)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-300">
|
||||
{t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)}
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && (
|
||||
<div className="border border-tertiary rounded-md overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-base-tertiary">
|
||||
<tr>
|
||||
<th className="text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$NAME)}
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$CREATED_AT)}
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$LAST_USED)}
|
||||
</th>
|
||||
<th className="text-right p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$ACTIONS)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="border-t border-tertiary">
|
||||
<td className="p-3 text-sm">{key.name}</td>
|
||||
<td className="p-3 text-sm">
|
||||
{formatDate(key.created_at)}
|
||||
</td>
|
||||
<td className="p-3 text-sm">
|
||||
{formatDate(key.last_used_at)}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
setKeyToDelete(key);
|
||||
setDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t(I18nKey.BUTTON$DELETE)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
<CreateApiKeyModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={handleCloseCreateModal}
|
||||
onKeyCreated={handleKeyCreated}
|
||||
/>
|
||||
|
||||
{/* Delete API Key Modal */}
|
||||
<DeleteApiKeyModal
|
||||
isOpen={deleteModalOpen}
|
||||
keyToDelete={keyToDelete}
|
||||
onClose={handleCloseDeleteModal}
|
||||
/>
|
||||
|
||||
{/* Show New API Key Modal */}
|
||||
<NewApiKeyModal
|
||||
isOpen={showNewKeyModal}
|
||||
newlyCreatedKey={newlyCreatedKey}
|
||||
onClose={handleCloseNewKeyModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandButtonProps {
|
||||
testId?: string;
|
||||
variant: "primary" | "secondary";
|
||||
variant: "primary" | "secondary" | "danger";
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["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,
|
||||
)}
|
||||
|
||||
@ -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 = (
|
||||
<>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={handleCreateKey}
|
||||
isDisabled={createApiKeyMutation.isPending || !newKeyName.trim()}
|
||||
>
|
||||
{createApiKeyMutation.isPending ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.BUTTON$CREATE)
|
||||
)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={handleCancel}
|
||||
isDisabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ApiKeyModalBase
|
||||
isOpen={isOpen}
|
||||
title={t(I18nKey.SETTINGS$CREATE_API_KEY)}
|
||||
footer={modalFooter}
|
||||
>
|
||||
<div data-testid="create-api-key-modal">
|
||||
<p className="text-sm text-gray-300">
|
||||
{t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)}
|
||||
</p>
|
||||
<SettingsInput
|
||||
testId="api-key-name-input"
|
||||
label={t(I18nKey.SETTINGS$NAME)}
|
||||
placeholder={t(I18nKey.SETTINGS$API_KEY_NAME_PLACEHOLDER)}
|
||||
value={newKeyName}
|
||||
onChange={(value) => setNewKeyName(value)}
|
||||
className="w-full mt-4"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</ApiKeyModalBase>
|
||||
);
|
||||
}
|
||||
@ -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 = (
|
||||
<>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="danger"
|
||||
className="grow"
|
||||
onClick={handleDeleteKey}
|
||||
isDisabled={deleteApiKeyMutation.isPending}
|
||||
>
|
||||
{deleteApiKeyMutation.isPending ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : (
|
||||
t(I18nKey.BUTTON$DELETE)
|
||||
)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onClose}
|
||||
isDisabled={deleteApiKeyMutation.isPending}
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ApiKeyModalBase
|
||||
isOpen={isOpen && !!keyToDelete}
|
||||
title={t(I18nKey.SETTINGS$DELETE_API_KEY)}
|
||||
footer={modalFooter}
|
||||
>
|
||||
<div data-testid="delete-api-key-modal">
|
||||
<p className="text-sm">
|
||||
{t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, {
|
||||
name: keyToDelete.name,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</ApiKeyModalBase>
|
||||
);
|
||||
}
|
||||
@ -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 = (
|
||||
<>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleCopyToClipboard}
|
||||
>
|
||||
{t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}
|
||||
</BrandButton>
|
||||
<BrandButton type="button" variant="secondary" onClick={onClose}>
|
||||
{t(I18nKey.BUTTON$CLOSE)}
|
||||
</BrandButton>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ApiKeyModalBase
|
||||
isOpen={isOpen && !!newlyCreatedKey}
|
||||
title={t(I18nKey.SETTINGS$API_KEY_CREATED)}
|
||||
width="600px"
|
||||
footer={modalFooter}
|
||||
>
|
||||
<div data-testid="new-api-key-modal">
|
||||
<p className="text-sm">{t(I18nKey.SETTINGS$API_KEY_WARNING)}</p>
|
||||
<div className="bg-base-tertiary p-4 rounded-md font-mono text-sm break-all mt-4">
|
||||
{newlyCreatedKey.key}
|
||||
</div>
|
||||
</div>
|
||||
</ApiKeyModalBase>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
</div>
|
||||
<input
|
||||
data-testid={testId}
|
||||
onChange={(e) => 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}
|
||||
|
||||
16
frontend/src/hooks/mutation/use-create-api-key.ts
Normal file
16
frontend/src/hooks/mutation/use-create-api-key.ts
Normal file
@ -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<CreateApiKeyResponse> =>
|
||||
ApiKeysClient.createApiKey(name),
|
||||
onSuccess: () => {
|
||||
// Invalidate the API keys query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
17
frontend/src/hooks/mutation/use-delete-api-key.ts
Normal file
17
frontend/src/hooks/mutation/use-delete-api-key.ts
Normal file
@ -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<void> => {
|
||||
await ApiKeysClient.deleteApiKey(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate the API keys query to trigger a refetch
|
||||
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
}
|
||||
22
frontend/src/hooks/query/use-api-keys.ts
Normal file
22
frontend/src/hooks/query/use-api-keys.ts
Normal file
@ -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
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "计划未创建",
|
||||
|
||||
@ -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"),
|
||||
|
||||
12
frontend/src/routes/api-keys.tsx
Normal file
12
frontend/src/routes/api-keys.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
|
||||
|
||||
function ApiKeysScreen() {
|
||||
return (
|
||||
<div className="flex flex-col grow overflow-auto p-11">
|
||||
<ApiKeysManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeysScreen;
|
||||
@ -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<GetConfigResponse>(["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();
|
||||
|
||||
@ -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 (
|
||||
<main
|
||||
@ -21,7 +20,7 @@ function SettingsScreen() {
|
||||
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
|
||||
</header>
|
||||
|
||||
{isSaas && billingIsEnabled && (
|
||||
{isSaas && (
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-12 px-11 border-b border-tertiary"
|
||||
@ -29,6 +28,7 @@ function SettingsScreen() {
|
||||
{[
|
||||
{ to: "/settings", text: "Account" },
|
||||
{ to: "/settings/billing", text: "Credits" },
|
||||
{ to: "/settings/api-keys", text: "API Keys" },
|
||||
].map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user