fix: simplify assistant key management

This commit is contained in:
Thomas Camlong committed 2026-08-10 19:09:04 +02:00
1 parent 533003e49b
commit 106fca2a61
5 files changed
+6 -180

No files matched your search

+2 -2
View File
@@ -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
@@ -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;
@@ -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<string>("/models");
const [apiKey, setApiKey] = useState("");
const [credentialFlow, setCredentialFlow] = useState<CredentialFlow>("idle");
const [credentialStep, setCredentialStep] = useState(0);
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
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<HTMLInputElement>(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 = () => {
</Box>
</Group>
<Group className={classes.credentialActions} gap="xs">
<Button
variant="light"
leftSection={<IconKey size={16} />}
onClick={() => {
setCredentialFlow("replace");
setCredentialStep(0);
}}
>
{t("apiKey.replace")}
</Button>
<Button variant="subtle" color="red" onClick={() => setCredentialFlow("remove")}>
{t("apiKey.remove")}
</Button>
</Group>
</Group>
) : credentialFlow === "replace" ? (
<Box className={classes.credentialFlow}>
<Stepper
className={classes.credentialStepper}
active={credentialStep}
size="sm"
allowNextStepsSelect={false}
>
<Stepper.Step label={t("apiKey.replaceFlow.enterStep")}>
<Stack gap="md">
<Box>
<Text fw={650}>{t("apiKey.replaceFlow.title")}</Text>
<Text size="sm" c="dimmed">
{t("apiKey.replaceFlow.description")}
</Text>
</Box>
<PasswordInput
label={t("apiKey.replaceFlow.newKey")}
description={t("apiKey.replaceFlow.newKeyDescription")}
value={apiKey}
onChange={(event) => setApiKey(event.currentTarget.value)}
leftSection={<IconKey size={16} />}
placeholder={t("apiKey.replacementPlaceholder")}
autoComplete="new-password"
ref={replacementKeyInputRef}
/>
<Box className={classes.flowActions}>
<Button variant="default" onClick={resetCredentialFlow}>
{t("apiKey.replaceFlow.cancel")}
</Button>
<Button
rightSection={<IconCheck size={16} />}
disabled={apiKey.trim().length === 0}
onClick={() => setCredentialStep(1)}
>
{t("apiKey.replaceFlow.review")}
</Button>
</Box>
</Stack>
</Stepper.Step>
<Stepper.Step label={t("apiKey.replaceFlow.reviewStep")}>
<Stack gap="md">
<Alert
color="yellow"
icon={<IconAlertTriangle size={18} />}
title={t("apiKey.replaceFlow.warningTitle")}
>
{t("apiKey.replaceFlow.warningDescription")}
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<Box>
<Text size="xs" c="dimmed">
{t("apiKey.replaceFlow.provider")}
</Text>
<Text size="sm" fw={600}>
{t(`provider.options.${provider}.label`)}
</Text>
</Box>
<Box>
<Text size="xs" c="dimmed">
{t("apiKey.replaceFlow.endpoint")}
</Text>
<Text size="sm" fw={600} truncate>
{normalizedBaseUrl}
</Text>
</Box>
</SimpleGrid>
<Box className={classes.flowActions}>
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setCredentialStep(0)}
>
{t("apiKey.replaceFlow.back")}
</Button>
<Button
color="yellow"
variant="filled"
leftSection={<IconKey size={16} />}
onClick={() => saveConnection()}
disabled={!connectionValid}
loading={updateConnection.isPending}
>
{t("apiKey.replaceFlow.confirm")}
</Button>
</Box>
</Stack>
</Stepper.Step>
</Stepper>
</Box>
) : credentialFlow === "remove" ? (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("apiKey.removeFlow.title")}>
<Stack gap="md">
+2 -22
View File
@@ -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,
);
-18
View File
@@ -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.",