fix: simplify assistant key management
This commit is contained in:
1 parent
dfafad15bf
commit
b7fdff9f7f
4 files changed
+6
-162
No files matched your search
@@ -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
|
||||
|
||||
-22
@@ -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;
|
||||
|
||||
+2
-116
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user