mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
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:
@@ -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,6 +25,12 @@ import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
|||||||
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||||
import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler';
|
import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler';
|
||||||
import { OAUTH_FLOW_TYPES } from '../../management';
|
import { OAUTH_FLOW_TYPES } from '../../management';
|
||||||
|
import {
|
||||||
|
registerAuthSession,
|
||||||
|
attachProcessToSession,
|
||||||
|
unregisterAuthSession,
|
||||||
|
authSessionEvents,
|
||||||
|
} from '../auth-session-manager';
|
||||||
|
|
||||||
/** Options for OAuth process execution */
|
/** Options for OAuth process execution */
|
||||||
export interface OAuthProcessOptions {
|
export interface OAuthProcessOptions {
|
||||||
@@ -326,6 +332,19 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
userCode: null,
|
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();
|
const startTime = Date.now();
|
||||||
|
|
||||||
authProcess.stdout?.on('data', async (data: Buffer) => {
|
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
|
// H5: Remove signal handlers before killing process
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
process.removeListener('SIGTERM', cleanup);
|
process.removeListener('SIGTERM', cleanup);
|
||||||
|
authSessionEvents.removeListener('session:cancelled', handleCancel);
|
||||||
|
unregisterAuthSession(state.sessionId);
|
||||||
authProcess.kill();
|
authProcess.kill();
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
|
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
|
// H5: Remove signal handlers to prevent memory leaks
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
process.removeListener('SIGTERM', cleanup);
|
process.removeListener('SIGTERM', cleanup);
|
||||||
|
authSessionEvents.removeListener('session:cancelled', handleCancel);
|
||||||
|
unregisterAuthSession(state.sessionId);
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
@@ -442,6 +465,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
// H5: Remove signal handlers to prevent memory leaks
|
// H5: Remove signal handlers to prevent memory leaks
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
process.removeListener('SIGTERM', cleanup);
|
process.removeListener('SIGTERM', cleanup);
|
||||||
|
authSessionEvents.removeListener('session:cancelled', handleCancel);
|
||||||
|
unregisterAuthSession(state.sessionId);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(fail(`Failed to start auth process: ${error.message}`));
|
console.log(fail(`Failed to start auth process: ${error.message}`));
|
||||||
resolve(null);
|
resolve(null);
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
submitProjectSelection,
|
submitProjectSelection,
|
||||||
getPendingSelection,
|
getPendingSelection,
|
||||||
} from '../../cliproxy/project-selection-handler';
|
} from '../../cliproxy/project-selection-handler';
|
||||||
|
import {
|
||||||
|
cancelAllSessionsForProvider,
|
||||||
|
hasActiveSession,
|
||||||
|
} from '../../cliproxy/auth-session-manager';
|
||||||
import { fetchCliproxyStats } from '../../cliproxy/stats-fetcher';
|
import { fetchCliproxyStats } from '../../cliproxy/stats-fetcher';
|
||||||
import {
|
import {
|
||||||
getAllAccountsSummary,
|
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
|
* GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt
|
||||||
* Returns project list for user to select from during OAuth flow
|
* 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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Loader2, ExternalLink, User, Download } from 'lucide-react';
|
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 { applyDefaultPreset } from '@/lib/preset-utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -40,10 +40,19 @@ export function AddAccountDialog({
|
|||||||
const [nickname, setNickname] = useState('');
|
const [nickname, setNickname] = useState('');
|
||||||
const startAuthMutation = useStartAuth();
|
const startAuthMutation = useStartAuth();
|
||||||
const kiroImportMutation = useKiroImport();
|
const kiroImportMutation = useKiroImport();
|
||||||
|
const cancelAuthMutation = useCancelAuth();
|
||||||
|
|
||||||
const isKiro = provider === 'kiro';
|
const isKiro = provider === 'kiro';
|
||||||
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
|
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isPending) {
|
||||||
|
cancelAuthMutation.mutate(provider);
|
||||||
|
}
|
||||||
|
setNickname('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleStartAuth = () => {
|
const handleStartAuth = () => {
|
||||||
startAuthMutation.mutate(
|
startAuthMutation.mutate(
|
||||||
{ provider, nickname: nickname.trim() || undefined },
|
{ provider, nickname: nickname.trim() || undefined },
|
||||||
@@ -83,9 +92,8 @@ export function AddAccountDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
if (!isOpen && !isPending) {
|
if (!isOpen) {
|
||||||
setNickname('');
|
handleCancel();
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +129,7 @@ export function AddAccountDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
<Button variant="ghost" onClick={handleCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{isKiro && (
|
{isKiro && (
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Sparkles } from 'lucide-react';
|
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 type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
@@ -42,6 +47,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
|||||||
const { data: authData, refetch } = useCliproxyAuth();
|
const { data: authData, refetch } = useCliproxyAuth();
|
||||||
const createMutation = useCreateVariant();
|
const createMutation = useCreateVariant();
|
||||||
const startAuthMutation = useStartAuth();
|
const startAuthMutation = useStartAuth();
|
||||||
|
const cancelAuthMutation = useCancelAuth();
|
||||||
const { privacyMode } = usePrivacy();
|
const { privacyMode } = usePrivacy();
|
||||||
|
|
||||||
// Get auth status for selected provider
|
// Get auth status for selected provider
|
||||||
@@ -146,6 +152,10 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
|||||||
// Prevent accidental close when user has made progress
|
// Prevent accidental close when user has made progress
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
// Cancel any in-progress auth when closing
|
||||||
|
if (startAuthMutation.isPending && selectedProvider) {
|
||||||
|
cancelAuthMutation.mutate(selectedProvider);
|
||||||
|
}
|
||||||
if (step === 'success' || step === 'provider') {
|
if (step === 'success' || step === 'provider') {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
|
||||||
interface AuthFlowState {
|
interface AuthFlowState {
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
@@ -82,9 +83,16 @@ export function useCliproxyAuthFlow() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const cancelAuth = useCallback(() => {
|
const cancelAuth = useCallback(() => {
|
||||||
|
const currentProvider = state.provider;
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
setState({ provider: null, isAuthenticating: false, error: null });
|
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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -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)
|
// Kiro IDE import hook (alternative auth path when OAuth callback fails)
|
||||||
export function useKiroImport() {
|
export function useKiroImport() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -357,6 +357,12 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ nickname }),
|
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) */
|
/** Import Kiro token from Kiro IDE (Kiro only) */
|
||||||
kiroImport: () =>
|
kiroImport: () =>
|
||||||
request<{ success: boolean; account: OAuthAccount | null; error?: string }>(
|
request<{ success: boolean; account: OAuthAccount | null; error?: string }>(
|
||||||
|
|||||||
Reference in New Issue
Block a user