diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index a3dab86bc..a3f8015a8 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -73,8 +73,8 @@ disabled, unavailable, or returns no models. API keys and additional header values are encrypted in the Homarr database. They are decrypted only on the server for discovery and model requests, and they are never returned to the browser. Changing the -provider or base URL clears existing credentials unless replacements are submitted with the change. This -prevents credentials for one service from being sent to another endpoint. +provider or base URL clears existing credentials. This prevents credentials for one service from being sent +to another endpoint. When a key is already stored, the page shows its protected state instead of an ambiguous empty password field. Removing the API key requires explicit confirmation. Save that removal before entering and saving a diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css index e4a9a3641..1aa50dd4d 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css @@ -68,24 +68,6 @@ min-width: 0; } -.credentialFlow { - padding: var(--mantine-spacing-md); - border: 1px solid light-dark(var(--mantine-color-yellow-4), var(--mantine-color-yellow-8)); - border-radius: var(--mantine-radius-md); - background-color: light-dark( - var(--mantine-color-yellow-0), - color-mix(in srgb, var(--mantine-color-yellow-9) 18%, transparent) - ); -} - -.credentialStepper :global(.mantine-Stepper-stepLabel) { - font-size: var(--mantine-font-size-sm); -} - -.credentialStepper :global(.mantine-Stepper-content) { - padding-top: var(--mantine-spacing-md); -} - .advancedControl { min-height: 3.25rem; } @@ -148,10 +130,6 @@ grid-template-columns: 1fr; } - .credentialStepper :global(.mantine-Stepper-stepLabel) { - display: none; - } - .selectedModel { align-items: stretch; flex-direction: column; diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index 782a91df8..61f05fefa 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Accordion, ActionIcon, @@ -17,10 +17,8 @@ import { Paper, PasswordInput, Select, - SimpleGrid, Skeleton, Stack, - Stepper, Switch, Text, TextInput, @@ -29,7 +27,6 @@ import { } from "@mantine/core"; import { IconAlertTriangle, - IconArrowLeft, IconCheck, IconCircleCheck, IconDatabaseSearch, @@ -60,7 +57,7 @@ import classes from "./assistant-configuration.module.css"; import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state"; type HeaderEntry = { id: number; name: string; value: string }; -type CredentialFlow = "idle" | "replace" | "remove"; +type CredentialFlow = "idle" | "remove"; type AssistantProviderOption = AssistantProvider | "homarr"; const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => { @@ -151,7 +148,6 @@ export const AssistantConfiguration = () => { const [modelDiscoveryPath, setModelDiscoveryPath] = useState("/models"); const [apiKey, setApiKey] = useState(""); const [credentialFlow, setCredentialFlow] = useState("idle"); - const [credentialStep, setCredentialStep] = useState(0); const [headers, setHeaders] = useState([]); const [clearHeaders, setClearHeaders] = useState(false); const [nextHeaderId, setNextHeaderId] = useState(1); @@ -159,7 +155,6 @@ export const AssistantConfiguration = () => { const [enabled, setEnabled] = useState(false); const [webSearchEnabled, setWebSearchEnabled] = useState(false); const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false); - const replacementKeyInputRef = useRef(null); useEffect(() => { setProvider(configuration?.provider ?? "openrouter"); @@ -170,7 +165,6 @@ export const AssistantConfiguration = () => { setWebSearchEnabled(configuration?.webSearchEnabled ?? false); setApiKey(""); setCredentialFlow("idle"); - setCredentialStep(0); }, [configuration]); const normalizedBaseUrl = baseUrl.trim().replace(/\/$/, ""); @@ -240,12 +234,6 @@ export const AssistantConfiguration = () => { } }, [modelId, models]); - useEffect(() => { - if (credentialFlow !== "replace" || credentialStep !== 0) return; - const animationFrame = requestAnimationFrame(() => replacementKeyInputRef.current?.focus()); - return () => cancelAnimationFrame(animationFrame); - }, [credentialFlow, credentialStep]); - const preset = assistantProviderPresets[provider]; const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0); const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({ @@ -265,7 +253,6 @@ export const AssistantConfiguration = () => { onSuccess: async ({ credentialsClearedForDestinationChange }) => { setApiKey(""); setCredentialFlow("idle"); - setCredentialStep(0); setHeaders([]); setClearHeaders(false); await Promise.all([ @@ -323,7 +310,6 @@ export const AssistantConfiguration = () => { const resetCredentialFlow = () => { setApiKey(""); setCredentialFlow("idle"); - setCredentialStep(0); }; const clearDraftConnectionState = () => { @@ -560,111 +546,11 @@ export const AssistantConfiguration = () => { - - ) : credentialFlow === "replace" ? ( - - - - - - {t("apiKey.replaceFlow.title")} - - {t("apiKey.replaceFlow.description")} - - - setApiKey(event.currentTarget.value)} - leftSection={} - placeholder={t("apiKey.replacementPlaceholder")} - autoComplete="new-password" - ref={replacementKeyInputRef} - /> - - - - - - - - - } - title={t("apiKey.replaceFlow.warningTitle")} - > - {t("apiKey.replaceFlow.warningDescription")} - - - - - {t("apiKey.replaceFlow.provider")} - - - {t(`provider.options.${provider}.label`)} - - - - - {t("apiKey.replaceFlow.endpoint")} - - - {normalizedBaseUrl} - - - - - - - - - - - ) : credentialFlow === "remove" ? ( } title={t("apiKey.removeFlow.title")}> diff --git a/e2e/assistant-management.spec.ts b/e2e/assistant-management.spec.ts index df2c00f61..79b7ccce9 100644 --- a/e2e/assistant-management.spec.ts +++ b/e2e/assistant-management.spec.ts @@ -12,7 +12,7 @@ const adminCredentials = { }; describe("Assistant management", () => { - test("keeps provider actions clear and the API key replacement flow responsive", async () => { + test("keeps provider actions clear and responsive", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); await seedAdminUserAsync(db, adminCredentials); await db.insert(sqliteSchema.assistantConfigurations).values({ @@ -51,20 +51,7 @@ describe("Assistant management", () => { await expect(page.getByText("Connection ready")).toBeVisible(); await expect(page.getByText("Key protected")).toBeVisible(); await expect(page.getByText("Encrypted API key saved")).toBeVisible(); - await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible(); await expect(page.getByRole("button", { name: "Remove saved API key" })).toBeVisible(); - - await page.getByRole("button", { name: "Replace key" }).click(); - const newKey = page.getByLabel("New API key"); - await expect(newKey).toBeFocused(); - await newKey.fill("replacement-test-api-key"); - await page.getByRole("button", { name: "Review replacement" }).click(); - await expect(page.getByText("The current key will stop being used")).toBeVisible(); - await expect(page.getByText("https://openrouter.ai/api/v1", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible(); - - await page.getByRole("button", { name: "Edit new key" }).click(); - await page.getByRole("button", { name: "Cancel replacement" }).click(); await page.getByRole("button", { name: "Additional request headers" }).click(); await expect(page.getByRole("button", { name: "Add header" })).toBeVisible(); @@ -77,15 +64,8 @@ describe("Assistant management", () => { await page.setViewportSize({ width: 390, height: 844 }); await page.reload(); await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove saved API key" })).toBeVisible(); await expect(page.getByRole("button", { name: "Save assistant" })).toBeVisible(); - await page.getByRole("button", { name: "Replace key" }).click(); - await expect(page.getByLabel("New API key")).toBeFocused(); - await page.getByLabel("New API key").fill("replacement-test-api-key"); - await page.getByRole("button", { name: "Review replacement" }).click(); - await expect(page.getByText("The current key will stop being used")).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit new key" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible(); const hasHorizontalOverflow = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, ); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 091773210..a7e875b9b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -6240,31 +6240,13 @@ "required": "Required by this provider. The value is encrypted and write-only.", "optional": "Optional for local endpoints. Leave it blank when the service does not require bearer authentication.", "placeholder": "Enter an API key", - "replacementPlaceholder": "Enter a replacement API key", "configuredBadge": "Saved key active", "pendingRemoval": "Removal pending", "savedTitle": "Encrypted API key saved", "savedDescription": "Homarr can use this key, but it cannot display or recover its value.", - "replace": "Replace key", "remove": "Remove saved API key", "removeDescription": "Deletes the saved key after you click Save provider connection.", "removalPendingDescription": "The saved key will be deleted when you save the provider connection.", - "replaceFlow": { - "title": "Replace the saved API key", - "description": "Enter the new key first. You will review the endpoint and impact before anything changes.", - "enterStep": "Enter new key", - "reviewStep": "Review change", - "newKey": "New API key", - "newKeyDescription": "The existing key remains active until you confirm the replacement.", - "cancel": "Cancel replacement", - "review": "Review replacement", - "warningTitle": "The current key will stop being used", - "warningDescription": "Confirming replaces the encrypted key for this endpoint. Homarr cannot restore the previous value, and model discovery and assistant requests will use the new key immediately.", - "provider": "Provider", - "endpoint": "Destination", - "back": "Edit new key", - "confirm": "Replace API key" - }, "removeFlow": { "title": "Remove the saved API key?", "description": "This provider requires an API key. Removing it can stop model discovery and assistant responses until another key is saved.",