mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 00:03:03 +00:00
🔑 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:
parent
a00800161c
commit
2c898b030b
7 changed files with 201 additions and 3 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
94
client/src/components/Nav/SettingsTabs/APIKeys/APIKeyRow.tsx
Normal file
94
client/src/components/Nav/SettingsTabs/APIKeys/APIKeyRow.tsx
Normal 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;
|
||||
53
client/src/components/Nav/SettingsTabs/APIKeys/APIKeys.tsx
Normal file
53
client/src/components/Nav/SettingsTabs/APIKeys/APIKeys.tsx
Normal 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);
|
||||
1
client/src/components/Nav/SettingsTabs/APIKeys/index.ts
Normal file
1
client/src/components/Nav/SettingsTabs/APIKeys/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './APIKeys';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue