diff --git a/frontend/src/components/Config/CreateTargetDialog.styles.ts b/frontend/src/components/Config/CreateTargetDialog.styles.ts index 34f023786..67fdb2aa2 100644 --- a/frontend/src/components/Config/CreateTargetDialog.styles.ts +++ b/frontend/src/components/Config/CreateTargetDialog.styles.ts @@ -6,4 +6,12 @@ export const useCreateTargetDialogStyles = makeStyles({ flexDirection: 'column', gap: tokens.spacingVerticalL, }, + warningMessage: { + width: '100%', + }, + warningMessageBody: { + whiteSpace: 'normal', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + }, }) diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx index b8a9dcfc0..cd096a192 100644 --- a/frontend/src/components/Config/CreateTargetDialog.test.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx @@ -497,4 +497,180 @@ describe("CreateTargetDialog", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + + it("should hide the API Key field and omit api_key/include auth_mode when Entra is selected", async () => { + const onCreated = jest.fn(); + const user = userEvent.setup(); + mockedTargetsApi.createTarget.mockResolvedValue({ + target_registry_name: "openai_chat_entra", + target_type: "OpenAIChatTarget", + }); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-resource.openai.azure.com/" }, + }); + + // check that API Key field is visible by default. + expect( + screen.getByPlaceholderText("API key (stored in memory only)") + ).toBeInTheDocument(); + + // Select Entra option. + await user.click( + screen.getByRole("radio", { + name: /Microsoft Entra Authentication/, + }) + ); + + // check that API Key field is hidden when Entra mode is selected. + expect( + screen.queryByPlaceholderText("API key (stored in memory only)") + ).not.toBeInTheDocument(); + + await user.click(screen.getByText("Create Target")); + + await waitFor(() => { + expect(mockedTargetsApi.createTarget).toHaveBeenCalledWith({ + type: "OpenAIChatTarget", + params: { + endpoint: "https://my-resource.openai.azure.com/", + }, + auth_mode: "entra", + }); + expect(onCreated).toHaveBeenCalled(); + }); + }); + + it("should clear a previously-typed API key when switching to Entra", async () => { + const onCreated = jest.fn(); + const user = userEvent.setup(); + mockedTargetsApi.createTarget.mockResolvedValue({ + target_registry_name: "openai_chat_entra", + target_type: "OpenAIChatTarget", + }); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-resource.openai.azure.com/" }, + }); + + // Type a key, then switch to Entra option. + fireEvent.change( + screen.getByPlaceholderText("API key (stored in memory only)"), + { target: { value: "sk-typed-before-switch" } } + ); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + await user.click(screen.getByText("Create Target")); + + await waitFor(() => { + const call = mockedTargetsApi.createTarget.mock.calls[0][0]; + expect(call.auth_mode).toBe("entra"); + expect(call.params).not.toHaveProperty("api_key"); + }); + }); + + it("should warn the user when Entra is selected for a non-Azure OpenAI endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { target: { value: "https://api.openai.com/" } }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + expect( + screen.getByText(/Entra auth only works with Azure OpenAI/) + ).toBeInTheDocument(); + }); + + it("should NOT warn when Entra is selected for a recognized Azure endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-resource.openai.azure.com/" }, + }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + expect( + screen.queryByText(/Entra auth only works with Azure OpenAI/) + ).not.toBeInTheDocument(); + }); + + it("should disable Create Target and skip API call for Entra + non-Azure OpenAI endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { target: { value: "https://api.test.com/" } }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + const createButton = screen.getByText("Create Target").closest("button"); + expect(createButton).toBeDisabled(); + + await user.click(screen.getByText("Create Target")); + + expect(mockedTargetsApi.createTarget).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx index 4bce08f56..94f433c17 100644 --- a/frontend/src/components/Config/CreateTargetDialog.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.tsx @@ -9,6 +9,8 @@ import { Button, Input, Label, + Radio, + RadioGroup, Select, Switch, Text, @@ -32,6 +34,26 @@ const TARGET_TYPE_CONFIG: Record = { const SUPPORTED_TARGET_TYPES = Object.keys(TARGET_TYPE_CONFIG) +type AuthMode = 'api_key' | 'entra' + +// Mirrors backend's hostname-suffix check (list in target_service.py). +// The backend still does the check and will reject unsupported endpoints, but this allows us to show a warning in the UI if the user selects Microsoft Entra authentication with a non-Azure OpenAI endpoint. +const AZURE_OPENAI_HOSTNAME_SUFFIXES = [ + '.openai.azure.com', + '.ai.azure.com', + '.services.ai.azure.com', + '.cognitiveservices.azure.com', +] + +function isAzureOpenAiEndpoint(endpoint: string): boolean { + try { + const host = new URL(endpoint).hostname.toLowerCase() + return AZURE_OPENAI_HOSTNAME_SUFFIXES.some((s) => host.endsWith(s)) + } catch { + return false + } +} + interface CreateTargetDialogProps { open: boolean onClose: () => void @@ -45,6 +67,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT const [modelName, setModelName] = useState('') const [hasDifferentUnderlying, setHasDifferentUnderlying] = useState(false) const [underlyingModel, setUnderlyingModel] = useState('') + const [authMode, setAuthMode] = useState('api_key') const [apiKey, setApiKey] = useState('') const [maxNewTokens, setMaxNewTokens] = useState('400') const [temperature, setTemperature] = useState('1.0') @@ -54,7 +77,11 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState<{ targetType?: string; endpoint?: string }>({}) - const isAzureML = TARGET_TYPE_CONFIG[targetType] === 'azureml' + const targetKind = TARGET_TYPE_CONFIG[targetType] + const isAzureML = targetKind === 'azureml' + const isOpenAi = targetKind === 'openai' + const isEntra = authMode === 'entra' + const showNonAzureEntraWarning = isEntra && isOpenAi && endpoint !== '' && !isAzureOpenAiEndpoint(endpoint) const resetForm = () => { setTargetType('') @@ -62,6 +89,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT setModelName('') setHasDifferentUnderlying(false) setUnderlyingModel('') + setAuthMode('api_key') setApiKey('') setMaxNewTokens('400') setTemperature('1.0') @@ -94,7 +122,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT endpoint, } if (modelName) params.model_name = modelName - if (apiKey) params.api_key = apiKey + if (!isEntra && apiKey) params.api_key = apiKey if (hasDifferentUnderlying && underlyingModel) params.underlying_model = underlyingModel @@ -112,6 +140,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT await targetsApi.createTarget({ type: targetType, params, + ...(isEntra ? { auth_mode: 'entra' as const } : {}), }) resetForm() onCreated() @@ -243,15 +272,40 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT )} - - setApiKey(data.value)} - /> + + { + const next = data.value as AuthMode + setAuthMode(next) + if (next === 'entra') setApiKey('') + }} + > + + + + {showNonAzureEntraWarning && ( + + + Error: Entra auth only works with Azure OpenAI / AI Foundry endpoints (for example, + *.openai.azure.com or *.ai.azure.com). + + + )} + + {!isEntra && ( + + setApiKey(data.value)} + /> + + )} +