mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
refactor(auth): save authType after successfully authenticated (#1036)
This commit is contained in:
@@ -839,5 +839,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving user settings file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type AuthType,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
AuthEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
@@ -25,11 +27,21 @@ export async function performInitialAuth(
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
await config.refreshAuth(authType, true);
|
||||
// The console.log is intentionally left out here.
|
||||
// We can add a dedicated startup message later if needed.
|
||||
|
||||
// Log authentication success
|
||||
const authEvent = new AuthEvent(authType, 'auto', 'success');
|
||||
logAuth(config, authEvent);
|
||||
} catch (e) {
|
||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
|
||||
// Log authentication failure
|
||||
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
|
||||
logAuth(config, authEvent);
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
logIdeConnection,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
|
||||
@@ -33,10 +33,18 @@ export async function initializeApp(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): Promise<InitializationResult> {
|
||||
const authError = await performInitialAuth(
|
||||
config,
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
);
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
const authError = await performInitialAuth(config, authType);
|
||||
|
||||
// Fallback to user select when initial authentication fails
|
||||
if (authError) {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type HistoryItem,
|
||||
ToolCallStatus,
|
||||
type HistoryItemWithoutId,
|
||||
AuthState,
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
@@ -93,6 +91,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
|
||||
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
@@ -349,20 +348,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
onAuthError,
|
||||
isAuthDialogOpen,
|
||||
isAuthenticating,
|
||||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, config);
|
||||
|
||||
// Qwen OAuth authentication state
|
||||
const {
|
||||
isQwenAuth,
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
cancelQwenAuth,
|
||||
} = useQwenAuth(settings, isAuthenticating);
|
||||
|
||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
@@ -371,19 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setModelSwitchedFromQuotaError,
|
||||
});
|
||||
|
||||
// Handle Qwen OAuth timeout
|
||||
const handleQwenAuthTimeout = useCallback(() => {
|
||||
onAuthError('Qwen OAuth authentication timed out. Please try again.');
|
||||
cancelQwenAuth();
|
||||
setAuthState(AuthState.Updating);
|
||||
}, [onAuthError, cancelQwenAuth, setAuthState]);
|
||||
|
||||
// Handle Qwen OAuth cancel
|
||||
const handleQwenAuthCancel = useCallback(() => {
|
||||
onAuthError('Qwen OAuth authentication cancelled.');
|
||||
cancelQwenAuth();
|
||||
setAuthState(AuthState.Updating);
|
||||
}, [onAuthError, cancelQwenAuth, setAuthState]);
|
||||
useInitializationAuthError(initializationResult.authError, onAuthError);
|
||||
|
||||
// Sync user tier from config when authentication changes
|
||||
// TODO: Implement getUserTier() method on Config if needed
|
||||
@@ -395,6 +375,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Check for enforced auth type mismatch
|
||||
useEffect(() => {
|
||||
// Check for initialization error first
|
||||
|
||||
if (
|
||||
settings.merged.security?.auth?.enforcedType &&
|
||||
settings.merged.security?.auth.selectedType &&
|
||||
@@ -959,7 +941,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleApprovalModeSelect,
|
||||
isAuthDialogOpen,
|
||||
handleAuthSelect,
|
||||
selectedAuthType: settings.merged.security?.auth?.selectedType,
|
||||
pendingAuthType,
|
||||
isEditorDialogOpen,
|
||||
exitEditorDialog,
|
||||
isSettingsDialogOpen,
|
||||
@@ -1201,7 +1183,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isVisionSwitchDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthDialogOpen ||
|
||||
(isAuthenticating && isQwenAuthenticating) ||
|
||||
isAuthenticating ||
|
||||
isEditorDialogOpen ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
@@ -1224,12 +1206,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isConfigInitialized,
|
||||
authError,
|
||||
isAuthDialogOpen,
|
||||
pendingAuthType,
|
||||
// Qwen OAuth state
|
||||
isQwenAuth,
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
@@ -1319,12 +1298,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isConfigInitialized,
|
||||
authError,
|
||||
isAuthDialogOpen,
|
||||
pendingAuthType,
|
||||
// Qwen OAuth state
|
||||
isQwenAuth,
|
||||
isQwenAuthenticating,
|
||||
deviceAuth,
|
||||
authStatus,
|
||||
authMessage,
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
@@ -1418,9 +1394,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleAuthSelect,
|
||||
setAuthState,
|
||||
onAuthError,
|
||||
// Qwen OAuth handlers
|
||||
handleQwenAuthTimeout,
|
||||
handleQwenAuthCancel,
|
||||
cancelAuthentication,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
@@ -1454,9 +1428,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleAuthSelect,
|
||||
setAuthState,
|
||||
onAuthError,
|
||||
// Qwen OAuth handlers
|
||||
handleQwenAuthTimeout,
|
||||
handleQwenAuthCancel,
|
||||
cancelAuthentication,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
|
||||
@@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { UIActionsContext } from '../contexts/UIActionsContext.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import type { UIActions } from '../contexts/UIActionsContext.js';
|
||||
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => {
|
||||
// AuthDialog only uses authError and pendingAuthType
|
||||
const baseState = {
|
||||
authError: null,
|
||||
pendingAuthType: undefined,
|
||||
} as Partial<UIState>;
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
...overrides,
|
||||
} as UIState;
|
||||
};
|
||||
|
||||
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
|
||||
// AuthDialog only uses handleAuthSelect
|
||||
const baseActions = {
|
||||
handleAuthSelect: vi.fn(),
|
||||
} as Partial<UIActions>;
|
||||
|
||||
return {
|
||||
...baseActions,
|
||||
...overrides,
|
||||
} as UIActions;
|
||||
};
|
||||
|
||||
const renderAuthDialog = (
|
||||
settings: LoadedSettings,
|
||||
uiStateOverrides: Partial<UIState> = {},
|
||||
uiActionsOverrides: Partial<UIActions> = {},
|
||||
) => {
|
||||
const uiState = createMockUIState(uiStateOverrides);
|
||||
const uiActions = createMockUIActions(uiActionsOverrides);
|
||||
|
||||
return renderWithProviders(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<AuthDialog />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>,
|
||||
{ settings },
|
||||
);
|
||||
};
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -66,13 +113,9 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog
|
||||
onSelect={() => {}}
|
||||
settings={settings}
|
||||
initialErrorMessage="GEMINI_API_KEY environment variable not found"
|
||||
/>,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings, {
|
||||
authError: 'GEMINI_API_KEY environment variable not found',
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'GEMINI_API_KEY environment variable not found',
|
||||
@@ -116,9 +159,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
@@ -162,9 +203,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
expect(lastFrame()).not.toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
@@ -208,9 +247,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
@@ -255,9 +292,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 2. OpenAI');
|
||||
@@ -297,9 +332,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Default is Qwen OAuth (first option)
|
||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||
@@ -341,9 +374,7 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
const { lastFrame } = renderAuthDialog(settings);
|
||||
|
||||
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
|
||||
// it will just show the default Qwen OAuth option
|
||||
@@ -352,7 +383,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('should prevent exiting when no auth method is selected and show error message', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const handleAuthSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
@@ -386,8 +417,10 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
const { lastFrame, stdin, unmount } = renderAuthDialog(
|
||||
settings,
|
||||
{},
|
||||
{ handleAuthSelect },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -395,16 +428,16 @@ describe('AuthDialog', () => {
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should show error message instead of calling onSelect
|
||||
// Should show error message instead of calling handleAuthSelect
|
||||
expect(lastFrame()).toContain(
|
||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||
);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(handleAuthSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not exit if there is already an error message', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const handleAuthSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
@@ -438,12 +471,10 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog
|
||||
onSelect={onSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage="Initial error"
|
||||
/>,
|
||||
const { lastFrame, stdin, unmount } = renderAuthDialog(
|
||||
settings,
|
||||
{ authError: 'Initial error' },
|
||||
{ handleAuthSelect },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -453,13 +484,13 @@ describe('AuthDialog', () => {
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should not call onSelect
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
// Should not call handleAuthSelect
|
||||
expect(handleAuthSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow exiting when auth method is already selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const handleAuthSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
@@ -493,8 +524,10 @@ describe('AuthDialog', () => {
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
const { stdin, unmount } = renderAuthDialog(
|
||||
settings,
|
||||
{},
|
||||
{ handleAuthSelect },
|
||||
);
|
||||
await wait();
|
||||
|
||||
@@ -502,8 +535,8 @@ describe('AuthDialog', () => {
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should call onSelect with undefined to exit
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
// Should call handleAuthSelect with undefined to exit
|
||||
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,26 +8,13 @@ import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
|
||||
interface AuthDialogProps {
|
||||
onSelect: (
|
||||
authMethod: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
) => void;
|
||||
settings: LoadedSettings;
|
||||
initialErrorMessage?: string | null;
|
||||
}
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
function parseDefaultAuthType(
|
||||
defaultAuthType: string | undefined,
|
||||
@@ -41,15 +28,14 @@ function parseDefaultAuthType(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AuthDialog({
|
||||
onSelect,
|
||||
settings,
|
||||
initialErrorMessage,
|
||||
}: AuthDialogProps): React.JSX.Element {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(
|
||||
initialErrorMessage || null,
|
||||
);
|
||||
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
|
||||
export function AuthDialog(): React.JSX.Element {
|
||||
const { pendingAuthType, authError } = useUIState();
|
||||
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||
const settings = useSettings();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: AuthType.QWEN_OAUTH,
|
||||
@@ -62,10 +48,17 @@ export function AuthDialog({
|
||||
const initialAuthIndex = Math.max(
|
||||
0,
|
||||
items.findIndex((item) => {
|
||||
// Priority 1: pendingAuthType
|
||||
if (pendingAuthType) {
|
||||
return item.value === pendingAuthType;
|
||||
}
|
||||
|
||||
// Priority 2: settings.merged.security?.auth?.selectedType
|
||||
if (settings.merged.security?.auth?.selectedType) {
|
||||
return item.value === settings.merged.security?.auth?.selectedType;
|
||||
}
|
||||
|
||||
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
||||
const defaultAuthType = parseDefaultAuthType(
|
||||
process.env['QWEN_DEFAULT_AUTH_TYPE'],
|
||||
);
|
||||
@@ -73,49 +66,29 @@ export function AuthDialog({
|
||||
return item.value === defaultAuthType;
|
||||
}
|
||||
|
||||
// Priority 4: default to QWEN_OAUTH
|
||||
return item.value === AuthType.QWEN_OAUTH;
|
||||
}),
|
||||
);
|
||||
|
||||
const handleAuthSelect = (authMethod: AuthType) => {
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
setShowOpenAIKeyPrompt(true);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
}
|
||||
}
|
||||
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
|
||||
const currentSelectedAuthType =
|
||||
selectedIndex !== null
|
||||
? items[selectedIndex]?.value
|
||||
: items[initialAuthIndex]?.value;
|
||||
|
||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||
setErrorMessage(null);
|
||||
await onAuthSelect(authMethod, SettingScope.User);
|
||||
};
|
||||
|
||||
const handleOpenAIKeySubmit = (
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
) => {
|
||||
setShowOpenAIKeyPrompt(false);
|
||||
onSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAIKeyCancel = () => {
|
||||
setShowOpenAIKeyPrompt(false);
|
||||
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
|
||||
const handleHighlight = (authMethod: AuthType) => {
|
||||
const index = items.findIndex((item) => item.value === authMethod);
|
||||
setSelectedIndex(index);
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (showOpenAIKeyPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
@@ -129,33 +102,11 @@ export function AuthDialog({
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSelect(undefined, SettingScope.User);
|
||||
onAuthSelect(undefined, SettingScope.User);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (showOpenAIKeyPrompt) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
onSubmit={handleOpenAIKeySubmit}
|
||||
onCancel={handleOpenAIKeyCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -174,16 +125,26 @@ export function AuthDialog({
|
||||
items={items}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleAuthSelect}
|
||||
onHighlight={handleHighlight}
|
||||
/>
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
{(authError || errorMessage) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{errorMessage}</Text>
|
||||
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Note: Your existing API key in settings.json will not be cleared
|
||||
when using Qwen OAuth. You can switch back to OpenAI authentication
|
||||
later if needed.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
|
||||
</Box>
|
||||
|
||||
@@ -6,27 +6,19 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import type { AuthType, Config } from '@qwen-code/qwen-code-core';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
AuthEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
|
||||
export function validateAuthMethodWithSettings(
|
||||
authType: AuthType,
|
||||
settings: LoadedSettings,
|
||||
): string | null {
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
|
||||
}
|
||||
if (settings.merged.security?.auth?.useExternal) {
|
||||
return null;
|
||||
}
|
||||
return validateAuthMethod(authType);
|
||||
}
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
const unAuthenticated =
|
||||
@@ -40,6 +32,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
|
||||
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
|
||||
pendingAuthType,
|
||||
isAuthenticating,
|
||||
);
|
||||
|
||||
const onAuthError = useCallback(
|
||||
(error: string | null) => {
|
||||
@@ -52,90 +52,123 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
[setAuthError, setAuthState],
|
||||
);
|
||||
|
||||
// Authentication flow
|
||||
useEffect(() => {
|
||||
const authFlow = async () => {
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
if (isAuthDialogOpen || !authType) {
|
||||
return;
|
||||
const handleAuthFailure = useCallback(
|
||||
(error: unknown) => {
|
||||
setIsAuthenticating(false);
|
||||
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
|
||||
onAuthError(errorMessage);
|
||||
|
||||
// Log authentication failure
|
||||
if (pendingAuthType) {
|
||||
const authEvent = new AuthEvent(
|
||||
pendingAuthType,
|
||||
'manual',
|
||||
'error',
|
||||
errorMessage,
|
||||
);
|
||||
logAuth(config, authEvent);
|
||||
}
|
||||
},
|
||||
[onAuthError, pendingAuthType, config],
|
||||
);
|
||||
|
||||
const validationError = validateAuthMethodWithSettings(
|
||||
authType,
|
||||
settings,
|
||||
);
|
||||
if (validationError) {
|
||||
onAuthError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
await config.refreshAuth(authType);
|
||||
console.log(`Authenticated via "${authType}".`);
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
} catch (e) {
|
||||
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
void authFlow();
|
||||
}, [isAuthDialogOpen, settings, config, onAuthError]);
|
||||
|
||||
// Handle auth selection from dialog
|
||||
const handleAuthSelect = useCallback(
|
||||
const handleAuthSuccess = useCallback(
|
||||
async (
|
||||
authType: AuthType | undefined,
|
||||
authType: AuthType,
|
||||
scope: SettingScope,
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
if (authType) {
|
||||
await clearCachedCredentialFile();
|
||||
try {
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
|
||||
// Save OpenAI credentials if provided
|
||||
if (credentials) {
|
||||
// Update Config's internal generationConfig before calling refreshAuth
|
||||
// This ensures refreshAuth has access to the new credentials
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
|
||||
// Also set environment variables for compatibility with other parts of the code
|
||||
if (credentials.apiKey) {
|
||||
// Only update credentials if not switching to QWEN_OAUTH,
|
||||
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
||||
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
||||
if (credentials?.apiKey != null) {
|
||||
settings.setValue(
|
||||
scope,
|
||||
'security.auth.apiKey',
|
||||
credentials.apiKey,
|
||||
);
|
||||
}
|
||||
if (credentials.baseUrl) {
|
||||
if (credentials?.baseUrl != null) {
|
||||
settings.setValue(
|
||||
scope,
|
||||
'security.auth.baseUrl',
|
||||
credentials.baseUrl,
|
||||
);
|
||||
}
|
||||
if (credentials.model) {
|
||||
if (credentials?.model != null) {
|
||||
settings.setValue(scope, 'model.name', credentials.model);
|
||||
}
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
setPendingAuthType(undefined);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
// Log authentication success
|
||||
const authEvent = new AuthEvent(authType, 'manual', 'success');
|
||||
logAuth(config, authEvent);
|
||||
},
|
||||
[settings, config],
|
||||
[settings, handleAuthFailure, config],
|
||||
);
|
||||
|
||||
const performAuth = useCallback(
|
||||
async (
|
||||
authType: AuthType,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
handleAuthSuccess(authType, scope, credentials);
|
||||
} catch (e) {
|
||||
handleAuthFailure(e);
|
||||
}
|
||||
},
|
||||
[config, handleAuthSuccess, handleAuthFailure],
|
||||
);
|
||||
|
||||
const handleAuthSelect = useCallback(
|
||||
async (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => {
|
||||
if (!authType) {
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAuthType(authType);
|
||||
setAuthError(null);
|
||||
setIsAuthDialogOpen(false);
|
||||
setIsAuthenticating(true);
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
if (credentials) {
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
await performAuth(authType, scope, credentials);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await performAuth(authType, scope);
|
||||
},
|
||||
[config, performAuth],
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
@@ -143,8 +176,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
}, []);
|
||||
|
||||
const cancelAuthentication = useCallback(() => {
|
||||
if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||
cancelQwenAuth();
|
||||
}
|
||||
|
||||
// Log authentication cancellation
|
||||
if (isAuthenticating && pendingAuthType) {
|
||||
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
|
||||
logAuth(config, authEvent);
|
||||
}
|
||||
|
||||
// Do not reset pendingAuthType here, persist the previously selected type.
|
||||
setIsAuthenticating(false);
|
||||
}, []);
|
||||
setIsAuthDialogOpen(true);
|
||||
setAuthError(null);
|
||||
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
|
||||
|
||||
/**
|
||||
/**
|
||||
* We previously used a useEffect to trigger authentication automatically when
|
||||
* settings.security.auth.selectedType changed. This caused problems: if authentication failed,
|
||||
* the UI could get stuck, since settings.json would update before success. Now, we
|
||||
* update selectedType in settings only when authentication fully succeeds.
|
||||
* Authentication is triggered explicitly—either during initial app startup or when the
|
||||
* user switches methods—not reactively through settings changes. This avoids repeated
|
||||
* or broken authentication cycles.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthType &&
|
||||
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
|
||||
defaultAuthType as AuthType,
|
||||
)
|
||||
) {
|
||||
onAuthError(
|
||||
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
|
||||
);
|
||||
}
|
||||
}, [onAuthError]);
|
||||
|
||||
return {
|
||||
authState,
|
||||
@@ -153,6 +223,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
onAuthError,
|
||||
isAuthDialogOpen,
|
||||
isAuthenticating,
|
||||
pendingAuthType,
|
||||
qwenAuthState,
|
||||
handleAuthSelect,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
@@ -26,6 +26,9 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
@@ -56,6 +59,16 @@ export const DialogManager = ({
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
|
||||
const getDefaultOpenAIConfig = () => {
|
||||
const fromSettings = settings.merged.security?.auth;
|
||||
const modelSettings = settings.merged.model;
|
||||
return {
|
||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||
};
|
||||
};
|
||||
|
||||
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
|
||||
return (
|
||||
<WelcomeBackDialog
|
||||
@@ -207,39 +220,56 @@ export const DialogManager = ({
|
||||
if (uiState.isVisionSwitchDialogOpen) {
|
||||
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
|
||||
}
|
||||
|
||||
if (uiState.isAuthDialogOpen || uiState.authError) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isAuthenticating) {
|
||||
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active
|
||||
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
|
||||
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
|
||||
const defaults = getDefaultOpenAIConfig();
|
||||
return (
|
||||
<QwenOAuthProgress
|
||||
deviceAuth={uiState.deviceAuth || undefined}
|
||||
authStatus={uiState.authStatus}
|
||||
authMessage={uiState.authMessage}
|
||||
onTimeout={uiActions.handleQwenAuthTimeout}
|
||||
onCancel={uiActions.handleQwenAuthCancel}
|
||||
<OpenAIKeyPrompt
|
||||
onSubmit={(apiKey, baseUrl, model) => {
|
||||
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
defaultApiKey={defaults.apiKey}
|
||||
defaultBaseUrl={defaults.baseUrl}
|
||||
defaultModel={defaults.model}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default auth progress for other auth types
|
||||
return (
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
uiActions.onAuthError('Authentication cancelled.');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog
|
||||
onSelect={uiActions.handleAuthSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage={uiState.authError}
|
||||
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||
return (
|
||||
<QwenOAuthProgress
|
||||
deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
|
||||
authStatus={uiState.qwenAuthState.authStatus}
|
||||
authMessage={uiState.qwenAuthState.authMessage}
|
||||
onTimeout={() => {
|
||||
uiActions.onAuthError('Qwen OAuth authentication timed out.');
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
onCancel={() => {
|
||||
uiActions.cancelAuthentication();
|
||||
uiActions.setAuthState(AuthState.Updating);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -18,6 +19,16 @@ interface OpenAIKeyPromptProps {
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export const credentialSchema = z.object({
|
||||
apiKey: z.string().min(1, 'API key is required'),
|
||||
baseUrl: z
|
||||
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
|
||||
.optional(),
|
||||
model: z.string().min(1, 'Model must be a non-empty string').optional(),
|
||||
});
|
||||
|
||||
export type OpenAICredentials = z.infer<typeof credentialSchema>;
|
||||
|
||||
export function OpenAIKeyPrompt({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
@@ -31,6 +42,34 @@ export function OpenAIKeyPrompt({
|
||||
const [currentField, setCurrentField] = useState<
|
||||
'apiKey' | 'baseUrl' | 'model'
|
||||
>('apiKey');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const validateAndSubmit = () => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const validated = credentialSchema.parse({
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: model.trim() || undefined,
|
||||
});
|
||||
|
||||
onSubmit(
|
||||
validated.apiKey,
|
||||
validated.baseUrl === '' ? '' : validated.baseUrl || '',
|
||||
validated.model || '',
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessage = error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
setValidationError(`Invalid credentials: ${errorMessage}`);
|
||||
} else {
|
||||
setValidationError('Failed to validate credentials');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -52,7 +91,7 @@ export function OpenAIKeyPrompt({
|
||||
} else if (currentField === 'model') {
|
||||
// 只有在提交时才检查 API key 是否为空
|
||||
if (apiKey.trim()) {
|
||||
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
|
||||
validateAndSubmit();
|
||||
} else {
|
||||
// 如果 API key 为空,回到 API key 字段
|
||||
setCurrentField('apiKey');
|
||||
@@ -168,6 +207,11 @@ export function OpenAIKeyPrompt({
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
OpenAI Configuration Required
|
||||
</Text>
|
||||
{validationError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{validationError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
Please enter your OpenAI configuration. You can get an API key from{' '}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import type { Key } from '../contexts/KeypressContext.js';
|
||||
|
||||
@@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => {
|
||||
let keypressHandler: ((key: Key) => void) | null = null;
|
||||
|
||||
const createMockDeviceAuth = (
|
||||
overrides: Partial<DeviceAuthorizationInfo> = {},
|
||||
): DeviceAuthorizationInfo => ({
|
||||
overrides: Partial<DeviceAuthorizationData> = {},
|
||||
): DeviceAuthorizationData => ({
|
||||
verification_uri: 'https://example.com/device',
|
||||
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
|
||||
user_code: 'ABC123',
|
||||
expires_in: 300,
|
||||
device_code: 'test-device-code',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => {
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<{
|
||||
deviceAuth: DeviceAuthorizationInfo;
|
||||
deviceAuth: DeviceAuthorizationData;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
@@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => {
|
||||
});
|
||||
|
||||
it('should format time correctly', () => {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 125, // 2 minutes and 5 seconds
|
||||
};
|
||||
@@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => {
|
||||
});
|
||||
|
||||
it('should format single digit seconds with leading zero', () => {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 67, // 1 minute and 7 seconds
|
||||
};
|
||||
@@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => {
|
||||
|
||||
describe('Timer functionality', () => {
|
||||
it('should countdown and call onTimeout when timer expires', async () => {
|
||||
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWithShortTime: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 2, // 2 seconds
|
||||
};
|
||||
@@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => {
|
||||
|
||||
describe('Props changes', () => {
|
||||
it('should display initial timer value from deviceAuth', () => {
|
||||
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
|
||||
const deviceAuthWith10Min: DeviceAuthorizationData = {
|
||||
...mockDeviceAuth,
|
||||
expires_in: 600, // 10 minutes
|
||||
};
|
||||
|
||||
@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
|
||||
import Link from 'ink-link';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { Colors } from '../colors.js';
|
||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
interface QwenOAuthProgressProps {
|
||||
onTimeout: () => void;
|
||||
onCancel: () => void;
|
||||
deviceAuth?: DeviceAuthorizationInfo;
|
||||
deviceAuth?: DeviceAuthorizationData;
|
||||
authStatus?:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (authStatus === 'timeout') {
|
||||
// Any key press in timeout state should trigger cancel to return to auth dialog
|
||||
if (authStatus === 'timeout' || authStatus === 'error') {
|
||||
// Any key press in timeout or error state should trigger cancel to return to auth dialog
|
||||
onCancel();
|
||||
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
onCancel();
|
||||
@@ -234,6 +234,35 @@ export function QwenOAuthProgress({
|
||||
);
|
||||
}
|
||||
|
||||
if (authStatus === 'error') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Error
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{authMessage ||
|
||||
'An error occurred during authentication. Please try again.'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state when no device auth is available yet
|
||||
if (!deviceAuth) {
|
||||
return (
|
||||
|
||||
@@ -487,8 +487,11 @@ describe('SettingsDialog', () => {
|
||||
it('loops back when reaching the end of an enum', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
const component = (
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
|
||||
export interface UIActions {
|
||||
handleThemeSelect: (
|
||||
@@ -30,12 +31,11 @@ export interface UIActions {
|
||||
handleAuthSelect: (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
credentials?: OpenAICredentials,
|
||||
) => Promise<void>;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
onAuthError: (error: string) => void;
|
||||
// Qwen OAuth handlers
|
||||
handleQwenAuthTimeout: () => void;
|
||||
handleQwenAuthCancel: () => void;
|
||||
cancelAuthentication: () => void;
|
||||
handleEditorSelect: (
|
||||
editorType: EditorType | undefined,
|
||||
scope: SettingScope,
|
||||
|
||||
@@ -16,10 +16,11 @@ import type {
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
||||
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type {
|
||||
AuthType,
|
||||
IdeContext,
|
||||
ApprovalMode,
|
||||
UserTierId,
|
||||
@@ -49,18 +50,9 @@ export interface UIState {
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
pendingAuthType: AuthType | undefined;
|
||||
// Qwen OAuth state
|
||||
isQwenAuth: boolean;
|
||||
isQwenAuthenticating: boolean;
|
||||
deviceAuth: DeviceAuthorizationInfo | null;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
| 'rate_limit';
|
||||
authMessage: string | null;
|
||||
qwenAuthState: QwenAuthState;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
corgiMode: boolean;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
|
||||
export interface DialogCloseOptions {
|
||||
// Theme dialog
|
||||
@@ -25,8 +26,9 @@ export interface DialogCloseOptions {
|
||||
handleAuthSelect: (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
credentials?: OpenAICredentials,
|
||||
) => Promise<void>;
|
||||
selectedAuthType: AuthType | undefined;
|
||||
pendingAuthType: AuthType | undefined;
|
||||
|
||||
// Editor dialog
|
||||
isEditorDialogOpen: boolean;
|
||||
|
||||
47
packages/cli/src/ui/hooks/useInitializationAuthError.ts
Normal file
47
packages/cli/src/ui/hooks/useInitializationAuthError.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that handles initialization authentication error only once.
|
||||
* This ensures that if an auth error occurred during app initialization,
|
||||
* it is reported to the user exactly once, even if the component re-renders.
|
||||
*
|
||||
* @param authError - The authentication error from initialization, or null if no error.
|
||||
* @param onAuthError - Callback function to handle the authentication error.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useInitializationAuthError(
|
||||
* initializationResult.authError,
|
||||
* onAuthError
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const useInitializationAuthError = (
|
||||
authError: string | null,
|
||||
onAuthError: (error: string) => void,
|
||||
): void => {
|
||||
const hasHandled = useRef(false);
|
||||
const authErrorRef = useRef(authError);
|
||||
const onAuthErrorRef = useRef(onAuthError);
|
||||
|
||||
// Update refs to always use latest values
|
||||
authErrorRef.current = authError;
|
||||
onAuthErrorRef.current = onAuthError;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHandled.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authErrorRef.current) {
|
||||
hasHandled.current = true;
|
||||
onAuthErrorRef.current(authErrorRef.current);
|
||||
}
|
||||
}, [authError, onAuthError]);
|
||||
};
|
||||
@@ -6,14 +6,13 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import type { DeviceAuthorizationInfo } from './useQwenAuth.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useQwenAuth } from './useQwenAuth.js';
|
||||
import {
|
||||
AuthType,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock the qwenOAuth2Events
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
@@ -36,24 +35,14 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
|
||||
|
||||
describe('useQwenAuth', () => {
|
||||
const mockDeviceAuth: DeviceAuthorizationInfo = {
|
||||
const mockDeviceAuth: DeviceAuthorizationData = {
|
||||
verification_uri: 'https://oauth.qwen.com/device',
|
||||
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
|
||||
user_code: 'ABC123',
|
||||
expires_in: 1800,
|
||||
device_code: 'device_code_123',
|
||||
};
|
||||
|
||||
const createMockSettings = (authType: AuthType): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: authType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -63,36 +52,33 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should initialize with default state when not Qwen auth', () => {
|
||||
const settings = createMockSettings(AuthType.USE_GEMINI);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
||||
const { result } = renderHook(() =>
|
||||
useQwenAuth(AuthType.USE_GEMINI, false),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isQwenAuthenticating: false,
|
||||
expect(result.current.qwenAuthState).toEqual({
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
isQwenAuth: false,
|
||||
cancelQwenAuth: expect.any(Function),
|
||||
});
|
||||
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should initialize with default state when Qwen auth but not authenticating', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
||||
const { result } = renderHook(() =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, false),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isQwenAuthenticating: false,
|
||||
expect(result.current.qwenAuthState).toEqual({
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
isQwenAuth: true,
|
||||
cancelQwenAuth: expect.any(Function),
|
||||
});
|
||||
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set up event listeners when Qwen auth and authenticating', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
renderHook(() => useQwenAuth(settings, true));
|
||||
renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
@@ -105,8 +91,7 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should handle device auth event', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -115,19 +100,17 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
});
|
||||
|
||||
it('should handle auth progress event - success', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -140,18 +123,19 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('success', 'Authentication successful!');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('success');
|
||||
expect(result.current.authMessage).toBe('Authentication successful!');
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('success');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Authentication successful!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - error', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -164,18 +148,19 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('error', 'Authentication failed');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('error');
|
||||
expect(result.current.authMessage).toBe('Authentication failed');
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('error');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Authentication failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - polling', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -188,20 +173,19 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('polling', 'Waiting for user authorization...');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.authMessage).toBe(
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Waiting for user authorization...',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event - rate_limit', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -214,7 +198,7 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!(
|
||||
@@ -223,14 +207,13 @@ describe('useQwenAuth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('rate_limit');
|
||||
expect(result.current.authMessage).toBe(
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle auth progress event without message', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleAuthProgress: (
|
||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||
message?: string,
|
||||
@@ -243,27 +226,30 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
act(() => {
|
||||
handleAuthProgress!('success');
|
||||
});
|
||||
|
||||
expect(result.current.authStatus).toBe('success');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('success');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should clean up event listeners when auth type changes', () => {
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { rerender } = renderHook(
|
||||
({ settings, isAuthenticating }) =>
|
||||
useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
||||
({ pendingAuthType, isAuthenticating }) =>
|
||||
useQwenAuth(pendingAuthType, isAuthenticating),
|
||||
{
|
||||
initialProps: {
|
||||
pendingAuthType: AuthType.QWEN_OAUTH,
|
||||
isAuthenticating: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Change to non-Qwen auth
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
||||
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
|
||||
|
||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||
QwenOAuth2Event.AuthUri,
|
||||
@@ -276,9 +262,9 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should clean up event listeners when authentication stops', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { rerender } = renderHook(
|
||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
||||
({ isAuthenticating }) =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
|
||||
{ initialProps: { isAuthenticating: true } },
|
||||
);
|
||||
|
||||
@@ -296,8 +282,9 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { unmount } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { unmount } = renderHook(() =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, true),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -312,8 +299,7 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
it('should reset state when switching from Qwen auth to another auth type', () => {
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -323,9 +309,14 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ settings, isAuthenticating }) =>
|
||||
useQwenAuth(settings, isAuthenticating),
|
||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
||||
({ pendingAuthType, isAuthenticating }) =>
|
||||
useQwenAuth(pendingAuthType, isAuthenticating),
|
||||
{
|
||||
initialProps: {
|
||||
pendingAuthType: AuthType.QWEN_OAUTH,
|
||||
isAuthenticating: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Simulate device auth
|
||||
@@ -333,22 +324,19 @@ describe('useQwenAuth', () => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
|
||||
// Switch to different auth type
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
||||
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should reset state when authentication stops', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -358,7 +346,8 @@ describe('useQwenAuth', () => {
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
||||
({ isAuthenticating }) =>
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
|
||||
{ initialProps: { isAuthenticating: true } },
|
||||
);
|
||||
|
||||
@@ -367,21 +356,19 @@ describe('useQwenAuth', () => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.authStatus).toBe('polling');
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||
|
||||
// Stop authentication
|
||||
rerender({ isAuthenticating: false });
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle cancelQwenAuth function', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||
|
||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||
if (event === QwenOAuth2Event.AuthUri) {
|
||||
@@ -390,53 +377,49 @@ describe('useQwenAuth', () => {
|
||||
return mockQwenOAuth2Events;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
// Set up some state
|
||||
act(() => {
|
||||
handleDeviceAuth!(mockDeviceAuth);
|
||||
});
|
||||
|
||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||
|
||||
// Cancel auth
|
||||
act(() => {
|
||||
result.current.cancelQwenAuth();
|
||||
});
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
||||
expect(result.current.deviceAuth).toBe(null);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.authMessage).toBe(null);
|
||||
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||
});
|
||||
|
||||
it('should maintain isQwenAuth flag correctly', () => {
|
||||
// Test with Qwen OAuth
|
||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
it('should handle different auth types correctly', () => {
|
||||
// Test with Qwen OAuth - should set up event listeners when authenticating
|
||||
const { result: qwenResult } = renderHook(() =>
|
||||
useQwenAuth(qwenSettings, false),
|
||||
useQwenAuth(AuthType.QWEN_OAUTH, true),
|
||||
);
|
||||
expect(qwenResult.current.isQwenAuth).toBe(true);
|
||||
expect(qwenResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
|
||||
|
||||
// Test with other auth types
|
||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
||||
// Test with other auth types - should not set up event listeners
|
||||
const { result: geminiResult } = renderHook(() =>
|
||||
useQwenAuth(geminiSettings, false),
|
||||
useQwenAuth(AuthType.USE_GEMINI, true),
|
||||
);
|
||||
expect(geminiResult.current.isQwenAuth).toBe(false);
|
||||
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
|
||||
const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE);
|
||||
const { result: oauthResult } = renderHook(() =>
|
||||
useQwenAuth(oauthSettings, false),
|
||||
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
|
||||
);
|
||||
expect(oauthResult.current.isQwenAuth).toBe(false);
|
||||
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||
});
|
||||
|
||||
it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => {
|
||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
||||
it('should initialize with idle status when starting authentication with Qwen auth', () => {
|
||||
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||
|
||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
||||
expect(result.current.authStatus).toBe('idle');
|
||||
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,23 +5,15 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import {
|
||||
AuthType,
|
||||
qwenOAuth2Events,
|
||||
QwenOAuth2Event,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export interface DeviceAuthorizationInfo {
|
||||
verification_uri: string;
|
||||
verification_uri_complete: string;
|
||||
user_code: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface QwenAuthState {
|
||||
isQwenAuthenticating: boolean;
|
||||
deviceAuth: DeviceAuthorizationInfo | null;
|
||||
export interface QwenAuthState {
|
||||
deviceAuth: DeviceAuthorizationData | null;
|
||||
authStatus:
|
||||
| 'idle'
|
||||
| 'polling'
|
||||
@@ -33,25 +25,22 @@ interface QwenAuthState {
|
||||
}
|
||||
|
||||
export const useQwenAuth = (
|
||||
settings: LoadedSettings,
|
||||
pendingAuthType: AuthType | undefined,
|
||||
isAuthenticating: boolean,
|
||||
) => {
|
||||
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
});
|
||||
|
||||
const isQwenAuth =
|
||||
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
|
||||
const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
|
||||
|
||||
// Set up event listeners when authentication starts
|
||||
useEffect(() => {
|
||||
if (!isQwenAuth || !isAuthenticating) {
|
||||
// Reset state when not authenticating or not Qwen auth
|
||||
setQwenAuthState({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
@@ -61,12 +50,11 @@ export const useQwenAuth = (
|
||||
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
isQwenAuthenticating: true,
|
||||
authStatus: 'idle',
|
||||
}));
|
||||
|
||||
// Set up event listeners
|
||||
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => {
|
||||
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
|
||||
setQwenAuthState((prev) => ({
|
||||
...prev,
|
||||
deviceAuth: {
|
||||
@@ -74,6 +62,7 @@ export const useQwenAuth = (
|
||||
verification_uri_complete: deviceAuth.verification_uri_complete,
|
||||
user_code: deviceAuth.user_code,
|
||||
expires_in: deviceAuth.expires_in,
|
||||
device_code: deviceAuth.device_code,
|
||||
},
|
||||
authStatus: 'polling',
|
||||
}));
|
||||
@@ -106,7 +95,6 @@ export const useQwenAuth = (
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
||||
|
||||
setQwenAuthState({
|
||||
isQwenAuthenticating: false,
|
||||
deviceAuth: null,
|
||||
authStatus: 'idle',
|
||||
authMessage: null,
|
||||
@@ -114,8 +102,7 @@ export const useQwenAuth = (
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...qwenAuthState,
|
||||
isQwenAuth,
|
||||
qwenAuthState,
|
||||
cancelQwenAuth,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user