- 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 000000000000..43d8ba89f028
--- /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 000000000000..2490d4bce04f
--- /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 && (
+
+
+
+
+
+ {t(I18nKey.SETTINGS$NAME)}
+ |
+
+ {t(I18nKey.SETTINGS$CREATED_AT)}
+ |
+
+ {t(I18nKey.SETTINGS$LAST_USED)}
+ |
+
+ {t(I18nKey.SETTINGS$ACTIONS)}
+ |
+
+
+
+ {apiKeys.map((key) => (
+
+ {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 03210f46e9e8..e13a2aa2d51c 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 000000000000..b97d29f349b6
--- /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 000000000000..187507745839
--- /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 000000000000..2457f6a46ebc
--- /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 75bec0c9c164..4aad109f249d 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({