🔑 feat: Surface User-Provided API Keys In Settings

Add a dedicated API Keys tab in user settings that lists endpoints
configured with `apiKey: "user_provided"` and reuses SetKeyDialog for
key entry and revocation. The tab is only shown when at least one
endpoint requires a user key.

Restores access to user-provided key entry when `modelSpecs.list` is
configured, since the sidebar endpoints dropdown is hidden by default
in that case (closes #12743).
This commit is contained in:
Danny Avila 2026-05-25 08:56:13 -04:00
parent a00800161c
commit 2c898b030b
7 changed files with 201 additions and 3 deletions

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useMemo, useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { MessageSquare, Command, DollarSign, Info } from 'lucide-react';
import { MessageSquare, Command, DollarSign, Info, KeyRound } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import {
GearIcon,
@ -21,22 +21,48 @@ import {
Data,
Balance,
Account,
APIKeys,
About,
} from './SettingsTabs';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
import { useLocalize, TranslationKeys } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import { cn } from '~/utils';
export default function Settings({ open, onOpenChange }: TDialogProps) {
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { data: startupConfig } = useGetStartupConfig();
const { data: endpointsConfig } = useGetEndpointsQuery();
const localize = useLocalize();
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
const tabRefs = useRef({});
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
const hasUserProvidedEndpoint = useMemo(() => {
if (!endpointsConfig) {
return false;
}
const values = Object.values(endpointsConfig);
for (let i = 0; i < values.length; i++) {
const config = values[i];
if (!config) {
continue;
}
if (
config.userProvide ||
config.userProvideURL ||
config.userProvideAccessKeyId ||
config.userProvideSecretAccessKey ||
config.userProvideSessionToken ||
config.userProvideBearerToken
) {
return true;
}
}
return false;
}, [endpointsConfig]);
useEffect(() => {
if (!aboutEnabled && activeTab === SettingsTabValues.ABOUT) {
setActiveTab(SettingsTabValues.GENERAL);
@ -53,6 +79,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
SettingsTabValues.DATA,
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
SettingsTabValues.ACCOUNT,
...(hasUserProvidedEndpoint ? [SettingsTabValues.API_KEYS] : []),
...(aboutEnabled ? [SettingsTabValues.ABOUT] : []),
];
const currentIndex = tabs.indexOf(activeTab);
@ -130,6 +157,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
icon: <UserIcon />,
label: 'com_nav_setting_account',
},
...(hasUserProvidedEndpoint
? [
{
value: SettingsTabValues.API_KEYS,
icon: <KeyRound className="icon-sm" aria-hidden="true" />,
label: 'com_nav_setting_api_keys' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
...(aboutEnabled
? [
{
@ -269,6 +305,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
{hasUserProvidedEndpoint && (
<Tabs.Content value={SettingsTabValues.API_KEYS} tabIndex={-1}>
<APIKeys />
</Tabs.Content>
)}
{aboutEnabled && (
<Tabs.Content value={SettingsTabValues.ABOUT} tabIndex={-1}>
<About />

View file

@ -0,0 +1,94 @@
import React, { useMemo, useState } from 'react';
import { Button } from '@librechat/client';
import { alternateName, getEndpointField } from 'librechat-data-provider';
import type { EModelEndpoint, TEndpointsConfig } from 'librechat-data-provider';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { icons } from '~/hooks/Endpoint/Icons';
import { useUserKey, useLocalize } from '~/hooks';
import { getIconKey } from '~/utils';
interface APIKeyRowProps {
endpoint: string;
endpointsConfig: TEndpointsConfig;
}
const APIKeyRow = ({ endpoint, endpointsConfig }: APIKeyRowProps) => {
const localize = useLocalize();
const [dialogOpen, setDialogOpen] = useState(false);
const { getExpiry, checkExpiry } = useUserKey(endpoint);
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType });
const Icon = icons[iconKey];
const label = useMemo(() => alternateName[endpoint] || endpoint, [endpoint]);
const expiry = getExpiry();
const hasKey = !!expiry && checkExpiry();
const expiryLabel = useMemo(() => {
if (!expiry) {
return localize('com_nav_api_keys_not_set');
}
if (expiry === 'never') {
return localize('com_endpoint_config_key_never_expires');
}
return `${localize('com_endpoint_config_key_encryption')} ${new Date(expiry).toLocaleString()}`;
}, [expiry, localize]);
return (
<>
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 items-center gap-3">
{Icon && (
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
{React.createElement(Icon, {
size: 20,
className: 'text-text-primary shrink-0 icon-md',
iconURL,
endpoint,
})}
</div>
)}
<div className="min-w-0">
<div className="truncate font-medium text-text-primary">{label}</div>
<div className="truncate text-xs text-text-secondary">{expiryLabel}</div>
</div>
</div>
<Button variant="outline" onClick={() => setDialogOpen(true)}>
{hasKey ? localize('com_ui_update') : localize('com_endpoint_config_key')}
</Button>
</div>
{dialogOpen && (
<SetKeyDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
endpoint={endpoint as EModelEndpoint}
endpointType={endpointType}
userProvideURL={getEndpointField(endpointsConfig, endpoint, 'userProvideURL')}
userProvideAccessKeyId={getEndpointField(
endpointsConfig,
endpoint,
'userProvideAccessKeyId',
)}
userProvideSecretAccessKey={getEndpointField(
endpointsConfig,
endpoint,
'userProvideSecretAccessKey',
)}
userProvideSessionToken={getEndpointField(
endpointsConfig,
endpoint,
'userProvideSessionToken',
)}
userProvideBearerToken={getEndpointField(
endpointsConfig,
endpoint,
'userProvideBearerToken',
)}
/>
)}
</>
);
};
export default APIKeyRow;

View file

@ -0,0 +1,53 @@
import React, { useMemo } from 'react';
import { useGetEndpointsQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import APIKeyRow from './APIKeyRow';
function APIKeys() {
const localize = useLocalize();
const { data: endpointsConfig } = useGetEndpointsQuery();
const userProvidedEndpoints = useMemo(() => {
if (!endpointsConfig) {
return [];
}
const entries = Object.entries(endpointsConfig);
const result: string[] = [];
for (let i = 0; i < entries.length; i++) {
const [endpoint, config] = entries[i];
if (!config) {
continue;
}
if (
config.userProvide ||
config.userProvideURL ||
config.userProvideAccessKeyId ||
config.userProvideSecretAccessKey ||
config.userProvideSessionToken ||
config.userProvideBearerToken
) {
result.push(endpoint);
}
}
return result;
}, [endpointsConfig]);
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<p className="pb-1 text-text-secondary">{localize('com_nav_api_keys_description')}</p>
{userProvidedEndpoints.length === 0 ? (
<div className="rounded-md border border-border-light p-4 text-text-secondary">
{localize('com_nav_api_keys_empty')}
</div>
) : (
<div className="divide-y divide-border-light">
{userProvidedEndpoints.map((endpoint) => (
<APIKeyRow key={endpoint} endpoint={endpoint} endpointsConfig={endpointsConfig} />
))}
</div>
)}
</div>
);
}
export default React.memo(APIKeys);

View file

@ -0,0 +1 @@
export { default } from './APIKeys';

View file

@ -4,6 +4,7 @@ export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as APIKeys } from './APIKeys';
export { default as Commands } from './Commands/Commands';
export { default as Personalization } from './Personalization';
export { default as About } from './About/About';

View file

@ -418,6 +418,9 @@
"com_nav_advanced_prompts": "Advanced prompts editor",
"com_nav_advanced_prompts_desc": "Enable versioning and production control for prompts",
"com_nav_always_make_prod": "Always make new prompt versions production",
"com_nav_api_keys_description": "Manage API keys for endpoints configured to use a user-provided key.",
"com_nav_api_keys_empty": "No endpoints currently require a user-provided API key.",
"com_nav_api_keys_not_set": "No key set",
"com_nav_archive_created_at": "Date Archived",
"com_nav_archive_name": "Name",
"com_nav_archived_chats": "Archived chats",
@ -594,6 +597,7 @@
"com_nav_send_message": "Send message",
"com_nav_setting_about": "About",
"com_nav_setting_account": "Account",
"com_nav_setting_api_keys": "API Keys",
"com_nav_setting_balance": "Balance",
"com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Data controls",

View file

@ -2106,6 +2106,10 @@ export enum SettingsTabValues {
* Tab for Account Settings
*/
ACCOUNT = 'account',
/**
* Tab for managing user-provided API keys
*/
API_KEYS = 'api_keys',
/**
* Chat input commands
*/