Add API keys management UI to settings page (#7710)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan 2025-04-23 11:08:32 -04:00 committed by GitHub
parent 1fd26d196a
commit fa559ace86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 642 additions and 26 deletions

View File

@ -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 () => {

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

@ -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": "计划未创建",

View File

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

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

View File

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

View File

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