Add Hugging Face OAuth support, add auth tab to settings (#9552)
Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
@@ -1312,6 +1312,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/provider-secrets": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "list_provider_secrets",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider secrets retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProviderSecretsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/provider-secrets/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "delete_provider_secret",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Provider secret identifier",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider secret deleted successfully",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid provider secret identifier"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/providers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -7154,6 +7214,91 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderSecret": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"provider",
|
||||
"provider_display_name",
|
||||
"name",
|
||||
"storage",
|
||||
"status",
|
||||
"configured",
|
||||
"has_secret",
|
||||
"can_delete",
|
||||
"can_configure"
|
||||
],
|
||||
"properties": {
|
||||
"can_configure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_delete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"configure_provider": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"configured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"has_secret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider_display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/ProviderSecretStatus"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/components/schemas/ProviderSecretStorage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderSecretStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"valid",
|
||||
"expired",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"ProviderSecretStorage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"secret_store",
|
||||
"provider_cache"
|
||||
]
|
||||
},
|
||||
"ProviderSecretsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"secrets"
|
||||
],
|
||||
"properties": {
|
||||
"secrets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProviderSecret"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderTemplate": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1030,6 +1030,29 @@ export type ProviderModelInfoQuery = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ProviderSecret = {
|
||||
can_configure: boolean;
|
||||
can_delete: boolean;
|
||||
configure_provider?: string | null;
|
||||
configured: boolean;
|
||||
expires_at?: string | null;
|
||||
has_secret: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
provider_display_name: string;
|
||||
status: ProviderSecretStatus;
|
||||
storage: ProviderSecretStorage;
|
||||
};
|
||||
|
||||
export type ProviderSecretStatus = 'valid' | 'expired' | 'unknown';
|
||||
|
||||
export type ProviderSecretStorage = 'secret_store' | 'provider_cache';
|
||||
|
||||
export type ProviderSecretsResponse = {
|
||||
secrets: Array<ProviderSecret>;
|
||||
};
|
||||
|
||||
export type ProviderTemplate = {
|
||||
api_url: string;
|
||||
doc_url: string;
|
||||
@@ -2718,6 +2741,61 @@ export type GetProviderCatalogTemplateResponses = {
|
||||
|
||||
export type GetProviderCatalogTemplateResponse = GetProviderCatalogTemplateResponses[keyof GetProviderCatalogTemplateResponses];
|
||||
|
||||
export type ListProviderSecretsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/provider-secrets';
|
||||
};
|
||||
|
||||
export type ListProviderSecretsErrors = {
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type ListProviderSecretsResponses = {
|
||||
/**
|
||||
* Provider secrets retrieved successfully
|
||||
*/
|
||||
200: ProviderSecretsResponse;
|
||||
};
|
||||
|
||||
export type ListProviderSecretsResponse = ListProviderSecretsResponses[keyof ListProviderSecretsResponses];
|
||||
|
||||
export type DeleteProviderSecretData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Provider secret identifier
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/config/provider-secrets/{id}';
|
||||
};
|
||||
|
||||
export type DeleteProviderSecretErrors = {
|
||||
/**
|
||||
* Invalid provider secret identifier
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type DeleteProviderSecretResponses = {
|
||||
/**
|
||||
* Provider secret deleted successfully
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type DeleteProviderSecretResponse = DeleteProviderSecretResponses[keyof DeleteProviderSecretResponses];
|
||||
|
||||
export type ProvidersData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Keyboard,
|
||||
HardDrive,
|
||||
Network,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import TunnelSection from './tunnel/TunnelSection';
|
||||
@@ -25,6 +26,7 @@ import GatewaySettingsSection from './gateways/GatewaySettingsSection';
|
||||
import { getTunnelStatus } from '../../api/sdk.gen';
|
||||
import ChatSettingsSection from './chat/ChatSettingsSection';
|
||||
import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
|
||||
import AuthSettingsSection from './auth/AuthSettingsSection';
|
||||
import LocalInferenceSection from './localInference/LocalInferenceSection';
|
||||
import MeshSection from './mesh/MeshSection';
|
||||
import { CONFIGURATION_ENABLED } from '../../updates';
|
||||
@@ -61,6 +63,10 @@ const i18n = defineMessages({
|
||||
id: 'settingsView.tabKeyboard',
|
||||
defaultMessage: 'Keyboard',
|
||||
},
|
||||
tabAuth: {
|
||||
id: 'settingsView.tabAuth',
|
||||
defaultMessage: 'Auth',
|
||||
},
|
||||
tabApp: {
|
||||
id: 'settingsView.tabApp',
|
||||
defaultMessage: 'App',
|
||||
@@ -109,6 +115,7 @@ export default function SettingsView({
|
||||
chat: 'chat',
|
||||
prompts: 'prompts',
|
||||
keyboard: 'keyboard',
|
||||
auth: 'auth',
|
||||
gateway: 'sharing',
|
||||
'local-inference': 'local-inference',
|
||||
mesh: 'mesh',
|
||||
@@ -242,6 +249,10 @@ export default function SettingsView({
|
||||
<Keyboard className="h-4 w-4" />
|
||||
{intl.formatMessage(i18n.tabKeyboard)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth" className="flex gap-2" data-testid="settings-auth-tab">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
{intl.formatMessage(i18n.tabAuth)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
|
||||
<Monitor className="h-4 w-4" />
|
||||
{intl.formatMessage(i18n.tabApp)}
|
||||
@@ -312,6 +323,13 @@ export default function SettingsView({
|
||||
<KeyboardShortcutsSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="auth"
|
||||
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<AuthSettingsSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="app"
|
||||
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, type RenderOptions } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AuthSettingsSection from './AuthSettingsSection';
|
||||
import {
|
||||
configureProviderOauth,
|
||||
deleteProviderSecret,
|
||||
listProviderSecrets,
|
||||
ProviderSecret,
|
||||
} from '../../../api';
|
||||
import { IntlTestWrapper } from '../../../i18n/test-utils';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
vi.mock('../../../api', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../api')>('../../../api');
|
||||
return {
|
||||
...actual,
|
||||
configureProviderOauth: vi.fn(),
|
||||
listProviderSecrets: vi.fn(),
|
||||
deleteProviderSecret: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../ModelAndProviderContext', () => ({
|
||||
useModelAndProvider: () => ({
|
||||
currentProvider: 'openai',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-toastify', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedListProviderSecrets = vi.mocked(listProviderSecrets);
|
||||
const mockedDeleteProviderSecret = vi.mocked(deleteProviderSecret);
|
||||
const mockedConfigureProviderOauth = vi.mocked(configureProviderOauth);
|
||||
const mockedToast = vi.mocked(toast);
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
|
||||
const providerSecret: ProviderSecret = {
|
||||
id: 'secret_store:openai:OPENAI_API_KEY',
|
||||
provider: 'openai',
|
||||
provider_display_name: 'OpenAI',
|
||||
name: 'OPENAI_API_KEY',
|
||||
storage: 'secret_store',
|
||||
expires_at: null,
|
||||
status: 'unknown',
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: null,
|
||||
};
|
||||
|
||||
const apiResult = <T,>(data: T) => ({
|
||||
data,
|
||||
request: {} as never,
|
||||
response: {} as never,
|
||||
});
|
||||
|
||||
describe('AuthSettingsSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [] }));
|
||||
mockedDeleteProviderSecret.mockResolvedValue(apiResult('ok'));
|
||||
mockedConfigureProviderOauth.mockResolvedValue(apiResult('ok'));
|
||||
});
|
||||
|
||||
it('renders an empty state when no credentials are stored', async () => {
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
expect(screen.getByText('Loading credentials...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No locally stored provider credentials were found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders provider credentials with storage and expiry status', async () => {
|
||||
mockedListProviderSecrets.mockResolvedValue(
|
||||
apiResult({
|
||||
secrets: [
|
||||
{
|
||||
...providerSecret,
|
||||
expires_at: '2027-01-01T12:00:00Z',
|
||||
status: 'valid',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('OPENAI_API_KEY')).toBeInTheDocument();
|
||||
expect(screen.getByText('Secret store')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Expires/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render an expiry badge when expiry is unknown', async () => {
|
||||
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [providerSecret] }));
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('Secret store')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Expiry unknown')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Expires/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a credential after confirmation and refreshes the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedListProviderSecrets
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [providerSecret] }))
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [] }));
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete credential' }));
|
||||
|
||||
expect(screen.getByText('Delete the OPENAI_API_KEY credential for OpenAI?')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This is the active provider. New requests may fail until you configure another credential.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith({
|
||||
path: { id: 'secret_store:openai:OPENAI_API_KEY' },
|
||||
throwOnError: true,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Credential deleted');
|
||||
});
|
||||
expect(await screen.findByText('No locally stored provider credentials were found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('configures the permanent Hugging Face credential row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const huggingFaceSecret: ProviderSecret = {
|
||||
id: 'provider_cache:huggingface',
|
||||
provider: 'huggingface',
|
||||
provider_display_name: 'Hugging Face',
|
||||
name: 'OAuth token',
|
||||
storage: 'provider_cache',
|
||||
expires_at: null,
|
||||
status: 'unknown',
|
||||
configured: false,
|
||||
has_secret: false,
|
||||
can_delete: false,
|
||||
can_configure: true,
|
||||
configure_provider: 'huggingface',
|
||||
};
|
||||
|
||||
mockedListProviderSecrets
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [huggingFaceSecret] }))
|
||||
.mockResolvedValueOnce(
|
||||
apiResult({
|
||||
secrets: [
|
||||
{
|
||||
...huggingFaceSecret,
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
expect(await screen.findByText('Hugging Face')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Delete credential' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedConfigureProviderOauth).toHaveBeenCalledWith({
|
||||
path: { name: 'huggingface' },
|
||||
throwOnError: true,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Credential configured');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { KeyRound, Loader2, LogIn, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
configureProviderOauth,
|
||||
deleteProviderSecret,
|
||||
listProviderSecrets,
|
||||
ProviderSecret,
|
||||
} from '../../../api';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import { useModelAndProvider } from '../../ModelAndProviderContext';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { ConfirmationModal } from '../../ui/ConfirmationModal';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
title: {
|
||||
id: 'authSettings.title',
|
||||
defaultMessage: 'Provider Credentials',
|
||||
},
|
||||
description: {
|
||||
id: 'authSettings.description',
|
||||
defaultMessage: 'Manage provider credentials stored locally by goose.',
|
||||
},
|
||||
loading: {
|
||||
id: 'authSettings.loading',
|
||||
defaultMessage: 'Loading credentials...',
|
||||
},
|
||||
empty: {
|
||||
id: 'authSettings.empty',
|
||||
defaultMessage: 'No locally stored provider credentials were found.',
|
||||
},
|
||||
failedToLoad: {
|
||||
id: 'authSettings.failedToLoad',
|
||||
defaultMessage: 'Failed to load provider credentials',
|
||||
},
|
||||
deleteTitle: {
|
||||
id: 'authSettings.deleteTitle',
|
||||
defaultMessage: 'Delete credential',
|
||||
},
|
||||
deleteMessage: {
|
||||
id: 'authSettings.deleteMessage',
|
||||
defaultMessage: 'Delete the {name} credential for {provider}?',
|
||||
},
|
||||
activeProviderWarning: {
|
||||
id: 'authSettings.activeProviderWarning',
|
||||
defaultMessage: 'This is the active provider. New requests may fail until you configure another credential.',
|
||||
},
|
||||
delete: {
|
||||
id: 'authSettings.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
cancel: {
|
||||
id: 'authSettings.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
deleted: {
|
||||
id: 'authSettings.deleted',
|
||||
defaultMessage: 'Credential deleted',
|
||||
},
|
||||
failedToDelete: {
|
||||
id: 'authSettings.failedToDelete',
|
||||
defaultMessage: 'Failed to delete credential: {error}',
|
||||
},
|
||||
storageSecretStore: {
|
||||
id: 'authSettings.storageSecretStore',
|
||||
defaultMessage: 'Secret store',
|
||||
},
|
||||
storageProviderCache: {
|
||||
id: 'authSettings.storageProviderCache',
|
||||
defaultMessage: 'Provider cache',
|
||||
},
|
||||
expiresAt: {
|
||||
id: 'authSettings.expiresAt',
|
||||
defaultMessage: 'Expires {date}',
|
||||
},
|
||||
deleteCredential: {
|
||||
id: 'authSettings.deleteCredential',
|
||||
defaultMessage: 'Delete credential',
|
||||
},
|
||||
signIn: {
|
||||
id: 'authSettings.signIn',
|
||||
defaultMessage: 'Sign in',
|
||||
},
|
||||
reauthorize: {
|
||||
id: 'authSettings.reauthorize',
|
||||
defaultMessage: 'Reauthorize',
|
||||
},
|
||||
signedIn: {
|
||||
id: 'authSettings.signedIn',
|
||||
defaultMessage: 'Credential configured',
|
||||
},
|
||||
failedToConfigure: {
|
||||
id: 'authSettings.failedToConfigure',
|
||||
defaultMessage: 'Failed to configure credential: {error}',
|
||||
},
|
||||
});
|
||||
|
||||
function storageLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
|
||||
if (secret.storage === 'provider_cache') {
|
||||
return intl.formatMessage(i18n.storageProviderCache);
|
||||
}
|
||||
return intl.formatMessage(i18n.storageSecretStore);
|
||||
}
|
||||
|
||||
function expiryLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
|
||||
if (!secret.expires_at) {
|
||||
return null;
|
||||
}
|
||||
return intl.formatMessage(i18n.expiresAt, {
|
||||
date: intl.formatDate(new Date(secret.expires_at), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function expiryClass(secret: ProviderSecret) {
|
||||
if (secret.status === 'expired') {
|
||||
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
|
||||
}
|
||||
return 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300';
|
||||
}
|
||||
|
||||
export default function AuthSettingsSection() {
|
||||
const intl = useIntl();
|
||||
const { currentProvider } = useModelAndProvider();
|
||||
const [secrets, setSecrets] = useState<ProviderSecret[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [configuringId, setConfiguringId] = useState<string | null>(null);
|
||||
const [secretToDelete, setSecretToDelete] = useState<ProviderSecret | null>(null);
|
||||
|
||||
const loadSecrets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProviderSecrets({ throwOnError: true });
|
||||
setSecrets(response.data?.secrets ?? []);
|
||||
} catch {
|
||||
toast.error(intl.formatMessage(i18n.failedToLoad));
|
||||
setSecrets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [intl]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSecrets();
|
||||
}, [loadSecrets]);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!secretToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingId(secretToDelete.id);
|
||||
try {
|
||||
await deleteProviderSecret({
|
||||
path: { id: secretToDelete.id },
|
||||
throwOnError: true,
|
||||
});
|
||||
toast.success(intl.formatMessage(i18n.deleted));
|
||||
setSecretToDelete(null);
|
||||
await loadSecrets();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
intl.formatMessage(i18n.failedToDelete, {
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const configureSecret = async (secret: ProviderSecret) => {
|
||||
if (!secret.configure_provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfiguringId(secret.id);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: secret.configure_provider },
|
||||
throwOnError: true,
|
||||
});
|
||||
toast.success(intl.formatMessage(i18n.signedIn));
|
||||
await loadSecrets();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
intl.formatMessage(i18n.failedToConfigure, {
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setConfiguringId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveProvider = secretToDelete?.provider === currentProvider;
|
||||
|
||||
return (
|
||||
<section id="auth" className="space-y-4 pr-4 mt-1">
|
||||
<Card className="pb-2">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
{intl.formatMessage(i18n.title)}
|
||||
</CardTitle>
|
||||
<CardDescription>{intl.formatMessage(i18n.description)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-text-secondary">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{intl.formatMessage(i18n.loading)}
|
||||
</div>
|
||||
) : secrets.length === 0 ? (
|
||||
<div className="py-6 text-sm text-text-secondary">{intl.formatMessage(i18n.empty)}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-primary">
|
||||
{secrets.map((secret) => (
|
||||
<div
|
||||
key={secret.id}
|
||||
className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
data-testid="auth-secret-row"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
{secret.provider_display_name}
|
||||
</h3>
|
||||
<span className="rounded border border-border-primary bg-background-secondary px-2 py-0.5 text-xs text-text-secondary">
|
||||
{storageLabel(secret, intl)}
|
||||
</span>
|
||||
{expiryLabel(secret, intl) && (
|
||||
<span
|
||||
className={`rounded border px-2 py-0.5 text-xs ${expiryClass(secret)}`}
|
||||
>
|
||||
{expiryLabel(secret, intl)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 break-all font-mono text-xs text-text-secondary">
|
||||
{secret.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
{secret.can_configure && secret.configure_provider && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={configuringId === secret.id}
|
||||
onClick={() => configureSecret(secret)}
|
||||
>
|
||||
{configuringId === secret.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : secret.has_secret || secret.configured ? (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
{secret.has_secret || secret.configured
|
||||
? intl.formatMessage(i18n.reauthorize)
|
||||
: intl.formatMessage(i18n.signIn)}
|
||||
</Button>
|
||||
)}
|
||||
{secret.can_delete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
shape="round"
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
disabled={deletingId === secret.id}
|
||||
onClick={() => setSecretToDelete(secret)}
|
||||
aria-label={intl.formatMessage(i18n.deleteCredential)}
|
||||
title={intl.formatMessage(i18n.deleteCredential)}
|
||||
>
|
||||
{deletingId === secret.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={!!secretToDelete}
|
||||
title={intl.formatMessage(i18n.deleteTitle)}
|
||||
message={
|
||||
secretToDelete
|
||||
? intl.formatMessage(i18n.deleteMessage, {
|
||||
name: secretToDelete.name,
|
||||
provider: secretToDelete.provider_display_name,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
detail={isActiveProvider ? intl.formatMessage(i18n.activeProviderWarning) : undefined}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setSecretToDelete(null)}
|
||||
confirmLabel={intl.formatMessage(i18n.delete)}
|
||||
cancelLabel={intl.formatMessage(i18n.cancel)}
|
||||
confirmVariant="destructive"
|
||||
isSubmitting={!!deletingId}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2, LogIn } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { configureProviderOauth, listProviderSecrets } from '../../../api';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
const HUGGINGFACE_PROVIDER = 'huggingface';
|
||||
const HUGGINGFACE_OAUTH_SECRET_ID = 'provider_cache:huggingface';
|
||||
|
||||
const i18n = defineMessages({
|
||||
title: {
|
||||
id: 'huggingFaceSignInPrompt.title',
|
||||
defaultMessage: 'Hugging Face',
|
||||
},
|
||||
signIn: {
|
||||
id: 'huggingFaceSignInPrompt.signIn',
|
||||
defaultMessage: 'Sign in',
|
||||
},
|
||||
signingIn: {
|
||||
id: 'huggingFaceSignInPrompt.signingIn',
|
||||
defaultMessage: 'Signing in...',
|
||||
},
|
||||
signedIn: {
|
||||
id: 'huggingFaceSignInPrompt.signedIn',
|
||||
defaultMessage: 'Hugging Face signed in',
|
||||
},
|
||||
failedToConfigure: {
|
||||
id: 'huggingFaceSignInPrompt.failedToConfigure',
|
||||
defaultMessage: 'Failed to sign in to Hugging Face: {error}',
|
||||
},
|
||||
});
|
||||
|
||||
interface HuggingFaceSignInPromptProps {
|
||||
description: string;
|
||||
className?: string;
|
||||
onSignedIn?: () => void;
|
||||
}
|
||||
|
||||
export default function HuggingFaceSignInPrompt({
|
||||
description,
|
||||
className,
|
||||
onSignedIn,
|
||||
}: HuggingFaceSignInPromptProps) {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProviderSecrets({ throwOnError: true });
|
||||
const huggingFaceSecret = response.data?.secrets.find(
|
||||
(secret) => secret.id === HUGGINGFACE_OAUTH_SECRET_ID
|
||||
);
|
||||
setLoggedIn(Boolean(huggingFaceSecret?.has_secret && huggingFaceSecret.status !== 'expired'));
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const signIn = async () => {
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: HUGGINGFACE_PROVIDER },
|
||||
throwOnError: true,
|
||||
});
|
||||
toast.success(intl.formatMessage(i18n.signedIn));
|
||||
setLoggedIn(true);
|
||||
onSignedIn?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
intl.formatMessage(i18n.failedToConfigure, {
|
||||
error: errorMessage(error, 'Unknown error'),
|
||||
})
|
||||
);
|
||||
await loadStatus();
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-3 rounded-lg border border-border-subtle bg-background-default p-3 sm:flex-row sm:items-center sm:justify-between ${className ?? ''}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-medium text-text-default">{intl.formatMessage(i18n.title)}</h4>
|
||||
<p className="mt-1 text-xs text-text-muted">{description}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 self-start sm:self-auto"
|
||||
disabled={signingIn}
|
||||
onClick={signIn}
|
||||
>
|
||||
{signingIn ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
|
||||
{signingIn ? intl.formatMessage(i18n.signingIn) : intl.formatMessage(i18n.signIn)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
|
||||
import { ModelSettingsPanel } from './ModelSettingsPanel';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
import HuggingFaceSignInPrompt from '../auth/HuggingFaceSignInPrompt';
|
||||
|
||||
const i18n = defineMessages({
|
||||
title: {
|
||||
@@ -96,6 +97,11 @@ const i18n = defineMessages({
|
||||
id: 'localInferenceSettings.visionEncoderNotDownloaded',
|
||||
defaultMessage: 'Vision encoder not downloaded',
|
||||
},
|
||||
huggingFaceSignInNote: {
|
||||
id: 'localInferenceSettings.huggingFaceSignInNote',
|
||||
defaultMessage:
|
||||
'Sign in to increase rate limits when searching and downloading models, and to access private or gated Hugging Face repositories.',
|
||||
},
|
||||
});
|
||||
|
||||
const VisionBadge = ({
|
||||
@@ -328,6 +334,8 @@ export const LocalInferenceSettings = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HuggingFaceSignInPrompt description={intl.formatMessage(i18n.huggingFaceSignInNote)} />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{downloads.size > 0 && (
|
||||
<div ref={downloadSectionRef}>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { Button } from '../../../../components/ui/button';
|
||||
import { errorMessage } from '../../../../utils/conversionUtils';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
import HuggingFaceSignInPrompt from '../../auth/HuggingFaceSignInPrompt';
|
||||
|
||||
const i18n = defineMessages({
|
||||
deleteConfigHeader: {
|
||||
@@ -114,6 +115,11 @@ const i18n = defineMessages({
|
||||
id: 'providerConfigurationModal.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
huggingFaceOAuthDescription: {
|
||||
id: 'providerConfigurationModal.huggingFaceOAuthDescription',
|
||||
defaultMessage:
|
||||
'Sign in to use Hugging Face Inference Providers without manually entering an API token.',
|
||||
},
|
||||
});
|
||||
|
||||
/** Render a setup step string, turning `backtick` spans into <code> and newlines into <br/>. */
|
||||
@@ -176,6 +182,7 @@ export default function ProviderConfigurationModal({
|
||||
const hasOAuth = provider.metadata.config_keys.some((key) => key.oauth_flow);
|
||||
const hasConfig = configKeys.length > 0;
|
||||
const hasDeviceCodeFlow = provider.metadata.config_keys.some((key) => key.device_code_flow);
|
||||
const isHuggingFaceProvider = provider.name === 'huggingface';
|
||||
|
||||
const isConfigured = provider.is_configured;
|
||||
const headerText = showDeleteConfirmation
|
||||
@@ -422,6 +429,20 @@ export default function ProviderConfigurationModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isHuggingFaceProvider && !hasOAuth && (
|
||||
<HuggingFaceSignInPrompt
|
||||
className="mb-4"
|
||||
description={intl.formatMessage(i18n.huggingFaceOAuthDescription)}
|
||||
onSignedIn={() => {
|
||||
if (onConfigured) {
|
||||
onConfigured(provider);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isExternalSetup && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-text-secondary">
|
||||
|
||||
@@ -44,6 +44,66 @@
|
||||
"appsView.title": {
|
||||
"defaultMessage": "Apps"
|
||||
},
|
||||
"authSettings.activeProviderWarning": {
|
||||
"defaultMessage": "This is the active provider. New requests may fail until you configure another credential."
|
||||
},
|
||||
"authSettings.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"authSettings.delete": {
|
||||
"defaultMessage": "Delete"
|
||||
},
|
||||
"authSettings.deleteCredential": {
|
||||
"defaultMessage": "Delete credential"
|
||||
},
|
||||
"authSettings.deleteMessage": {
|
||||
"defaultMessage": "Delete the {name} credential for {provider}?"
|
||||
},
|
||||
"authSettings.deleteTitle": {
|
||||
"defaultMessage": "Delete credential"
|
||||
},
|
||||
"authSettings.deleted": {
|
||||
"defaultMessage": "Credential deleted"
|
||||
},
|
||||
"authSettings.description": {
|
||||
"defaultMessage": "Manage provider credentials stored locally by goose."
|
||||
},
|
||||
"authSettings.empty": {
|
||||
"defaultMessage": "No locally stored provider credentials were found."
|
||||
},
|
||||
"authSettings.expiresAt": {
|
||||
"defaultMessage": "Expires {date}"
|
||||
},
|
||||
"authSettings.failedToConfigure": {
|
||||
"defaultMessage": "Failed to configure credential: {error}"
|
||||
},
|
||||
"authSettings.failedToDelete": {
|
||||
"defaultMessage": "Failed to delete credential: {error}"
|
||||
},
|
||||
"authSettings.failedToLoad": {
|
||||
"defaultMessage": "Failed to load provider credentials"
|
||||
},
|
||||
"authSettings.loading": {
|
||||
"defaultMessage": "Loading credentials..."
|
||||
},
|
||||
"authSettings.reauthorize": {
|
||||
"defaultMessage": "Reauthorize"
|
||||
},
|
||||
"authSettings.signIn": {
|
||||
"defaultMessage": "Sign in"
|
||||
},
|
||||
"authSettings.signedIn": {
|
||||
"defaultMessage": "Credential configured"
|
||||
},
|
||||
"authSettings.storageProviderCache": {
|
||||
"defaultMessage": "Provider cache"
|
||||
},
|
||||
"authSettings.storageSecretStore": {
|
||||
"defaultMessage": "Secret store"
|
||||
},
|
||||
"authSettings.title": {
|
||||
"defaultMessage": "Provider Credentials"
|
||||
},
|
||||
"backButton.back": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
@@ -1466,6 +1526,21 @@
|
||||
"huggingFaceModelSearch.tooLarge": {
|
||||
"defaultMessage": "May not fit in memory ({size} model, {available} available)"
|
||||
},
|
||||
"huggingFaceSignInPrompt.failedToConfigure": {
|
||||
"defaultMessage": "Failed to sign in to Hugging Face: {error}"
|
||||
},
|
||||
"huggingFaceSignInPrompt.signIn": {
|
||||
"defaultMessage": "Sign in"
|
||||
},
|
||||
"huggingFaceSignInPrompt.signedIn": {
|
||||
"defaultMessage": "Hugging Face signed in"
|
||||
},
|
||||
"huggingFaceSignInPrompt.signingIn": {
|
||||
"defaultMessage": "Signing in..."
|
||||
},
|
||||
"huggingFaceSignInPrompt.title": {
|
||||
"defaultMessage": "Hugging Face"
|
||||
},
|
||||
"imagePreview.altText": {
|
||||
"defaultMessage": "goose image"
|
||||
},
|
||||
@@ -1805,6 +1880,9 @@
|
||||
"localInferenceSettings.featuredModels": {
|
||||
"defaultMessage": "Featured Models"
|
||||
},
|
||||
"localInferenceSettings.huggingFaceSignInNote": {
|
||||
"defaultMessage": "Sign in to increase rate limits when searching and downloading models, and to access private or gated Hugging Face repositories."
|
||||
},
|
||||
"localInferenceSettings.modelSettings": {
|
||||
"defaultMessage": "Model Settings"
|
||||
},
|
||||
@@ -2801,6 +2879,9 @@
|
||||
"providerConfigurationModal.goBack": {
|
||||
"defaultMessage": "Go Back"
|
||||
},
|
||||
"providerConfigurationModal.huggingFaceOAuthDescription": {
|
||||
"defaultMessage": "Sign in to use Hugging Face Inference Providers without manually entering an API token."
|
||||
},
|
||||
"providerConfigurationModal.oauthLoginFailed": {
|
||||
"defaultMessage": "OAuth login failed: {error}"
|
||||
},
|
||||
@@ -4055,6 +4136,9 @@
|
||||
"settingsView.tabApp": {
|
||||
"defaultMessage": "App"
|
||||
},
|
||||
"settingsView.tabAuth": {
|
||||
"defaultMessage": "Auth"
|
||||
},
|
||||
"settingsView.tabChat": {
|
||||
"defaultMessage": "Chat"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user