fix(ui): enable cancel button during OAuth authentication

Resolves #234 - Cancel button was disabled during authentication flow,
preventing users from canceling the OAuth process.

Changes:
- Add auth-session-manager.ts for tracking active OAuth sessions
- Add POST /cliproxy/auth/:provider/cancel endpoint to abort sessions
- Kill spawned CLIProxy auth process when cancel is triggered
- Enable Cancel button in AddAccountDialog during authentication
- Add cancel support to QuickSetupWizard auth step
- Update useCancelAuth hook to call backend cancel endpoint
This commit is contained in:
kaitranntt
2025-12-31 18:29:04 -05:00
parent a24ae6ab3b
commit 86200eb698
8 changed files with 226 additions and 7 deletions
+119
View File
@@ -0,0 +1,119 @@
/**
* Auth Session Manager
*
* Tracks active OAuth sessions and provides cancellation capability.
* Used to properly terminate in-progress OAuth flows from UI.
*/
import { EventEmitter } from 'events';
import { ChildProcess } from 'child_process';
export interface ActiveAuthSession {
sessionId: string;
provider: string;
startedAt: number;
process?: ChildProcess;
}
export const authSessionEvents = new EventEmitter();
const activeSessions = new Map<string, ActiveAuthSession>();
/**
* Register an active OAuth session
*/
export function registerAuthSession(
sessionId: string,
provider: string,
process?: ChildProcess
): void {
activeSessions.set(sessionId, {
sessionId,
provider,
startedAt: Date.now(),
process,
});
authSessionEvents.emit('session:started', sessionId, provider);
}
/**
* Update session with process reference (if registered before spawn)
*/
export function attachProcessToSession(sessionId: string, process: ChildProcess): void {
const session = activeSessions.get(sessionId);
if (session) {
session.process = process;
}
}
/**
* Unregister an auth session (on completion or cancellation)
*/
export function unregisterAuthSession(sessionId: string): void {
activeSessions.delete(sessionId);
authSessionEvents.emit('session:ended', sessionId);
}
/**
* Cancel an active OAuth session
* Returns true if session was found and killed
*/
export function cancelAuthSession(sessionId: string): boolean {
const session = activeSessions.get(sessionId);
if (!session) {
return false;
}
// Kill the process if attached
if (session.process && !session.process.killed) {
session.process.kill('SIGTERM');
}
activeSessions.delete(sessionId);
authSessionEvents.emit('session:cancelled', sessionId);
return true;
}
/**
* Get active session by session ID
*/
export function getActiveSession(sessionId: string): ActiveAuthSession | null {
return activeSessions.get(sessionId) || null;
}
/**
* Get active session for a provider (most recent)
*/
export function getActiveSessionForProvider(provider: string): ActiveAuthSession | null {
for (const session of activeSessions.values()) {
if (session.provider === provider) {
return session;
}
}
return null;
}
/**
* Check if there's an active session for provider
*/
export function hasActiveSession(provider: string): boolean {
return getActiveSessionForProvider(provider) !== null;
}
/**
* Cancel all sessions for a provider
*/
export function cancelAllSessionsForProvider(provider: string): number {
let count = 0;
for (const [sessionId, session] of activeSessions.entries()) {
if (session.provider === provider) {
if (session.process && !session.process.killed) {
session.process.kill('SIGTERM');
}
activeSessions.delete(sessionId);
authSessionEvents.emit('session:cancelled', sessionId);
count++;
}
}
return count;
}
+25
View File
@@ -25,6 +25,12 @@ import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import { isAuthenticated, registerAccountFromToken } from './token-manager';
import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler';
import { OAUTH_FLOW_TYPES } from '../../management';
import {
registerAuthSession,
attachProcessToSession,
unregisterAuthSession,
authSessionEvents,
} from '../auth-session-manager';
/** Options for OAuth process execution */
export interface OAuthProcessOptions {
@@ -326,6 +332,19 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
userCode: null,
};
// Register session for cancellation support
registerAuthSession(state.sessionId, provider);
attachProcessToSession(state.sessionId, authProcess);
// Listen for external cancel signal
const handleCancel = (cancelledSessionId: string) => {
if (cancelledSessionId === state.sessionId && authProcess && !authProcess.killed) {
log('Session cancelled externally');
authProcess.kill('SIGTERM');
}
};
authSessionEvents.on('session:cancelled', handleCancel);
const startTime = Date.now();
authProcess.stdout?.on('data', async (data: Buffer) => {
@@ -377,6 +396,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// H5: Remove signal handlers before killing process
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
authProcess.kill();
console.log('');
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
@@ -391,6 +412,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
@@ -442,6 +465,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
console.log('');
console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null);
@@ -13,6 +13,10 @@ import {
submitProjectSelection,
getPendingSelection,
} from '../../cliproxy/project-selection-handler';
import {
cancelAllSessionsForProvider,
hasActiveSession,
} from '../../cliproxy/auth-session-manager';
import { fetchCliproxyStats } from '../../cliproxy/stats-fetcher';
import {
getAllAccountsSummary,
@@ -316,6 +320,35 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
});
/**
* POST /api/cliproxy/auth/:provider/cancel - Cancel in-progress OAuth flow
* Terminates the OAuth process for the specified provider
*/
router.post('/:provider/cancel', (req: Request, res: Response): void => {
const { provider } = req.params;
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
// Check if there's an active session
if (!hasActiveSession(provider)) {
res.status(404).json({ error: 'No active authentication session for this provider' });
return;
}
// Cancel all sessions for this provider
const cancelledCount = cancelAllSessionsForProvider(provider);
res.json({
success: true,
cancelled: cancelledCount,
provider,
});
});
/**
* GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt
* Returns project list for user to select from during OAuth flow
@@ -17,7 +17,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, ExternalLink, User, Download } from 'lucide-react';
import { useStartAuth, useKiroImport } from '@/hooks/use-cliproxy';
import { useStartAuth, useKiroImport, useCancelAuth } from '@/hooks/use-cliproxy';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { toast } from 'sonner';
@@ -40,10 +40,19 @@ export function AddAccountDialog({
const [nickname, setNickname] = useState('');
const startAuthMutation = useStartAuth();
const kiroImportMutation = useKiroImport();
const cancelAuthMutation = useCancelAuth();
const isKiro = provider === 'kiro';
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
const handleCancel = () => {
if (isPending) {
cancelAuthMutation.mutate(provider);
}
setNickname('');
onClose();
};
const handleStartAuth = () => {
startAuthMutation.mutate(
{ provider, nickname: nickname.trim() || undefined },
@@ -83,9 +92,8 @@ export function AddAccountDialog({
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen && !isPending) {
setNickname('');
onClose();
if (!isOpen) {
handleCancel();
}
};
@@ -121,7 +129,7 @@ export function AddAccountDialog({
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
{isKiro && (
+11 -1
View File
@@ -15,7 +15,12 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { Sparkles } from 'lucide-react';
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
import {
useCliproxyAuth,
useCreateVariant,
useStartAuth,
useCancelAuth,
} from '@/hooks/use-cliproxy';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { usePrivacy } from '@/contexts/privacy-context';
@@ -42,6 +47,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
const { data: authData, refetch } = useCliproxyAuth();
const createMutation = useCreateVariant();
const startAuthMutation = useStartAuth();
const cancelAuthMutation = useCancelAuth();
const { privacyMode } = usePrivacy();
// Get auth status for selected provider
@@ -146,6 +152,10 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
// Prevent accidental close when user has made progress
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
// Cancel any in-progress auth when closing
if (startAuthMutation.isPending && selectedProvider) {
cancelAuthMutation.mutate(selectedProvider);
}
if (step === 'success' || step === 'provider') {
onClose();
return;
+9 -1
View File
@@ -6,6 +6,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '@/lib/api-client';
interface AuthFlowState {
provider: string | null;
@@ -82,9 +83,16 @@ export function useCliproxyAuthFlow() {
);
const cancelAuth = useCallback(() => {
const currentProvider = state.provider;
abortControllerRef.current?.abort();
setState({ provider: null, isAuthenticating: false, error: null });
}, []);
// Also cancel on backend
if (currentProvider) {
api.cliproxy.auth.cancel(currentProvider).catch(() => {
// Ignore errors - session may have already completed
});
}
}, [state.provider]);
return useMemo(
() => ({
+10
View File
@@ -136,6 +136,16 @@ export function useStartAuth() {
});
}
// Cancel OAuth flow hook
export function useCancelAuth() {
return useMutation({
mutationFn: (provider: string) => api.cliproxy.auth.cancel(provider),
onError: (error: Error) => {
toast.error(error.message);
},
});
}
// Kiro IDE import hook (alternative auth path when OAuth callback fails)
export function useKiroImport() {
const queryClient = useQueryClient();
+6
View File
@@ -357,6 +357,12 @@ export const api = {
method: 'POST',
body: JSON.stringify({ nickname }),
}),
/** Cancel in-progress OAuth flow */
cancel: (provider: string) =>
request<{ success: boolean; cancelled: number; provider: string }>(
`/cliproxy/auth/${provider}/cancel`,
{ method: 'POST' }
),
/** Import Kiro token from Kiro IDE (Kiro only) */
kiroImport: () =>
request<{ success: boolean; account: OAuthAccount | null; error?: string }>(