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, );