feat(auth): add auth failure detection modal for Claude CLI 401 errors (#1361)
* feat(auth): add auth failure detection modal for Claude CLI 401 errors Detect authentication failures (401 errors) from Claude CLI and display a modal prompting the user to re-authenticate. This improves UX by providing clear feedback when tokens expire, are invalid, or are missing. Changes: - Add AuthFailureInfo interface for auth failure events - Add CLAUDE_AUTH_FAILURE IPC channel for main→renderer communication - Add auth-failure event handler in agent-events-handlers.ts - Add AuthFailureModal component with i18n translation support - Add useAuthFailureStore Zustand store for modal state management - Wire up IPC listeners in useIpc.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(i18n): add missing auth.failure translation keys and fix review issues Address PR review findings: - Add auth.failure.* translation keys to en/common.json and fr/common.json - Fix 'common.dismiss' → 'labels.dismiss' for correct i18n key path - Fix hardcoded 'Unknown Profile' to use translation key - Replace dynamic require() with static import for claude-profile-manager - Add TODO comment for hasPendingAuthFailure explaining intended use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add IPC serialization note to AuthFailureInfo.detectedAt Clarifies that Date objects become ISO strings when sent over IPC. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -709,6 +709,7 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
# Get OAuth token - Claude CLI handles token lifecycle internally
|
||||
oauth_token = require_auth_token()
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../shared/constants/phase-protocol";
|
||||
import type {
|
||||
SDKRateLimitInfo,
|
||||
AuthFailureInfo,
|
||||
Task,
|
||||
TaskStatus,
|
||||
Project,
|
||||
@@ -26,6 +27,7 @@ import { persistPlanStatusSync, getPlanPath } from "./task/plan-file-utils";
|
||||
import { findTaskWorktree } from "../worktree-paths";
|
||||
import { findTaskAndProject } from "./task/shared";
|
||||
import { safeSendToRenderer } from "./utils";
|
||||
import { getClaudeProfileManager } from "../claude-profile-manager";
|
||||
|
||||
/**
|
||||
* Validates status transitions to prevent invalid state changes.
|
||||
@@ -133,6 +135,34 @@ export function registerAgenteventsHandlers(
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
});
|
||||
|
||||
// Handle auth failure events (401 errors requiring re-authentication)
|
||||
agentManager.on("auth-failure", (taskId: string, authFailure: {
|
||||
profileId?: string;
|
||||
failureType?: 'missing' | 'invalid' | 'expired' | 'unknown';
|
||||
message?: string;
|
||||
originalError?: string;
|
||||
}) => {
|
||||
console.warn(`[AgentEvents] Auth failure detected for task ${taskId}:`, authFailure);
|
||||
|
||||
// Get profile name for display
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = authFailure.profileId
|
||||
? profileManager.getProfile(authFailure.profileId)
|
||||
: profileManager.getActiveProfile();
|
||||
|
||||
const authFailureInfo: AuthFailureInfo = {
|
||||
profileId: authFailure.profileId || profile?.id || 'unknown',
|
||||
profileName: profile?.name,
|
||||
failureType: authFailure.failureType || 'unknown',
|
||||
message: authFailure.message || 'Authentication failed. Please re-authenticate.',
|
||||
originalError: authFailure.originalError,
|
||||
taskId,
|
||||
detectedAt: new Date(),
|
||||
};
|
||||
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
|
||||
});
|
||||
|
||||
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => {
|
||||
// Get project info early for multi-project filtering (issue #723)
|
||||
const { project: exitProject } = findTaskAndProject(taskId);
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface TerminalAPI {
|
||||
fetchClaudeUsage: (terminalId: string) => Promise<IPCResult>;
|
||||
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<import('../../shared/types').ClaudeProfile | null>>;
|
||||
onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void;
|
||||
onAuthFailure: (callback: (info: import('../../shared/types').AuthFailureInfo) => void) => () => void;
|
||||
retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest) => Promise<IPCResult>;
|
||||
|
||||
// Usage Monitoring (Proactive Account Switching)
|
||||
@@ -485,6 +486,21 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
};
|
||||
},
|
||||
|
||||
onAuthFailure: (
|
||||
callback: (info: import('../../shared/types').AuthFailureInfo) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
info: import('../../shared/types').AuthFailureInfo
|
||||
): void => {
|
||||
callback(info);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request),
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { AgentTools } from './components/AgentTools';
|
||||
import { WelcomeScreen } from './components/WelcomeScreen';
|
||||
import { RateLimitModal } from './components/RateLimitModal';
|
||||
import { SDKRateLimitModal } from './components/SDKRateLimitModal';
|
||||
import { AuthFailureModal } from './components/AuthFailureModal';
|
||||
import { OnboardingWizard } from './components/onboarding';
|
||||
import { AppUpdateNotification } from './components/AppUpdateNotification';
|
||||
import { ProactiveSwapListener } from './components/ProactiveSwapListener';
|
||||
@@ -1075,6 +1076,9 @@ export function App() {
|
||||
{/* SDK Rate Limit Modal - shows when SDK/CLI operations hit limits (changelog, tasks, etc.) */}
|
||||
<SDKRateLimitModal />
|
||||
|
||||
{/* Auth Failure Modal - shows when Claude CLI encounters 401/auth errors */}
|
||||
<AuthFailureModal onOpenSettings={() => setIsSettingsDialogOpen(true)} />
|
||||
|
||||
{/* Onboarding Wizard - shows on first launch when onboardingCompleted is false */}
|
||||
<OnboardingWizard
|
||||
open={isOnboardingWizardOpen}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Settings } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { useAuthFailureStore } from '../stores/auth-failure-store';
|
||||
|
||||
interface AuthFailureModalProps {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal displayed when Claude CLI encounters an authentication failure (401 error).
|
||||
* Prompts the user to re-authenticate via Settings > Claude Profiles.
|
||||
*/
|
||||
export function AuthFailureModal({ onOpenSettings }: AuthFailureModalProps) {
|
||||
const { isModalOpen, authFailureInfo, hideAuthFailureModal, clearAuthFailure } = useAuthFailureStore();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
if (!authFailureInfo) return null;
|
||||
|
||||
const profileName = authFailureInfo.profileName || t('auth.failure.unknownProfile', 'Unknown Profile');
|
||||
|
||||
// Get user-friendly message for the auth failure type
|
||||
const getFailureMessage = () => {
|
||||
switch (authFailureInfo.failureType) {
|
||||
case 'expired':
|
||||
return t('auth.failure.tokenExpired', 'Your authentication token has expired.');
|
||||
case 'invalid':
|
||||
return t('auth.failure.tokenInvalid', 'Your authentication token is invalid.');
|
||||
case 'missing':
|
||||
return t('auth.failure.tokenMissing', 'No authentication token found.');
|
||||
default:
|
||||
return t('auth.failure.authFailed', 'Authentication failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const failureMessage = getFailureMessage();
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
hideAuthFailureModal();
|
||||
onOpenSettings?.();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
clearAuthFailure();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={(open) => !open && hideAuthFailureModal()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-amber-100 dark:bg-amber-900/30 p-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-lg">
|
||||
{t('auth.failure.title', 'Authentication Required')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
{t('auth.failure.profileLabel', 'Profile')}: {profileName}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-foreground">
|
||||
{failureMessage}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('auth.failure.description', 'Please re-authenticate your Claude profile to continue using Auto Claude.')}
|
||||
</p>
|
||||
|
||||
{authFailureInfo.taskId && (
|
||||
<div className="rounded-md bg-muted p-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
{t('auth.failure.taskAffected', 'Task affected')}: <span className="font-mono">{authFailureInfo.taskId}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authFailureInfo.originalError && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t('auth.failure.technicalDetails', 'Technical details')}
|
||||
</summary>
|
||||
<pre className="mt-2 rounded-md bg-muted p-2 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{authFailureInfo.originalError}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={handleDismiss} className="sm:mr-auto">
|
||||
{t('labels.dismiss', 'Dismiss')}
|
||||
</Button>
|
||||
<Button onClick={handleGoToSettings} className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('auth.failure.goToSettings', 'Go to Settings')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { unstable_batchedUpdates } from 'react-dom';
|
||||
import { useTaskStore } from '../stores/task-store';
|
||||
import { useRoadmapStore } from '../stores/roadmap-store';
|
||||
import { useRateLimitStore } from '../stores/rate-limit-store';
|
||||
import { useAuthFailureStore } from '../stores/auth-failure-store';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo } from '../../shared/types';
|
||||
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo, AuthFailureInfo } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Batched update queue for IPC events.
|
||||
@@ -333,6 +334,20 @@ export function useIpcListeners(): void {
|
||||
}
|
||||
);
|
||||
|
||||
// Auth failure listener (401 errors requiring re-authentication)
|
||||
const showAuthFailureModal = useAuthFailureStore.getState().showAuthFailureModal;
|
||||
const cleanupAuthFailure = window.electronAPI.onAuthFailure(
|
||||
(info: AuthFailureInfo) => {
|
||||
// Convert detectedAt string to Date if needed
|
||||
showAuthFailureModal({
|
||||
...info,
|
||||
detectedAt: typeof info.detectedAt === 'string'
|
||||
? new Date(info.detectedAt)
|
||||
: info.detectedAt
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
// Flush any pending batched updates before cleanup
|
||||
@@ -352,6 +367,7 @@ export function useIpcListeners(): void {
|
||||
cleanupRoadmapStopped();
|
||||
cleanupRateLimit();
|
||||
cleanupSDKRateLimit();
|
||||
cleanupAuthFailure();
|
||||
};
|
||||
}, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, batchAppendLogs, setError]);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export const claudeProfileMock = {
|
||||
|
||||
onSDKRateLimit: () => () => {},
|
||||
|
||||
onAuthFailure: () => () => {},
|
||||
|
||||
retryWithProfile: async () => ({ success: true }),
|
||||
|
||||
// Usage Monitoring (Proactive Account Switching)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
import type { AuthFailureInfo } from '../../shared/types';
|
||||
|
||||
interface AuthFailureState {
|
||||
// Auth failure modal state
|
||||
isModalOpen: boolean;
|
||||
authFailureInfo: AuthFailureInfo | null;
|
||||
|
||||
// TODO: Use hasPendingAuthFailure to show a badge/indicator in the sidebar
|
||||
// when there's an unresolved auth failure (e.g., red dot on Settings icon)
|
||||
hasPendingAuthFailure: boolean;
|
||||
|
||||
// Actions
|
||||
showAuthFailureModal: (info: AuthFailureInfo) => void;
|
||||
hideAuthFailureModal: () => void;
|
||||
clearAuthFailure: () => void;
|
||||
}
|
||||
|
||||
export const useAuthFailureStore = create<AuthFailureState>((set) => ({
|
||||
isModalOpen: false,
|
||||
authFailureInfo: null,
|
||||
hasPendingAuthFailure: false,
|
||||
|
||||
showAuthFailureModal: (info: AuthFailureInfo) => {
|
||||
set({
|
||||
isModalOpen: true,
|
||||
authFailureInfo: info,
|
||||
hasPendingAuthFailure: true,
|
||||
});
|
||||
},
|
||||
|
||||
hideAuthFailureModal: () => {
|
||||
// Keep the failure info when closing so user can see it again
|
||||
set({ isModalOpen: false });
|
||||
},
|
||||
|
||||
clearAuthFailure: () => {
|
||||
set({
|
||||
isModalOpen: false,
|
||||
authFailureInfo: null,
|
||||
hasPendingAuthFailure: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -121,6 +121,8 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// SDK/CLI rate limit event (for non-terminal Claude invocations)
|
||||
CLAUDE_SDK_RATE_LIMIT: 'claude:sdkRateLimit',
|
||||
// Auth failure event (401 errors requiring re-authentication)
|
||||
CLAUDE_AUTH_FAILURE: 'claude:authFailure',
|
||||
// Retry a rate-limited operation with a different profile
|
||||
CLAUDE_RETRY_WITH_PROFILE: 'claude:retryWithProfile',
|
||||
|
||||
|
||||
@@ -449,5 +449,20 @@
|
||||
"step2": "Find the profile in the Claude Accounts section",
|
||||
"step3": "Click \"Authenticate\" to complete login",
|
||||
"footer": "The account will be available once you complete authentication."
|
||||
},
|
||||
"auth": {
|
||||
"failure": {
|
||||
"title": "Authentication Required",
|
||||
"profileLabel": "Profile",
|
||||
"unknownProfile": "Unknown Profile",
|
||||
"tokenExpired": "Your authentication token has expired.",
|
||||
"tokenInvalid": "Your authentication token is invalid.",
|
||||
"tokenMissing": "No authentication token found.",
|
||||
"authFailed": "Authentication failed.",
|
||||
"description": "Please re-authenticate your Claude profile to continue using Auto Claude.",
|
||||
"taskAffected": "Task affected",
|
||||
"technicalDetails": "Technical details",
|
||||
"goToSettings": "Go to Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,5 +449,20 @@
|
||||
"step2": "Trouvez le profil dans la section Comptes Claude",
|
||||
"step3": "Cliquez sur « Authentifier » pour terminer la connexion",
|
||||
"footer": "Le compte sera disponible une fois l'authentification terminée."
|
||||
},
|
||||
"auth": {
|
||||
"failure": {
|
||||
"title": "Authentification requise",
|
||||
"profileLabel": "Profil",
|
||||
"unknownProfile": "Profil inconnu",
|
||||
"tokenExpired": "Votre jeton d'authentification a expiré.",
|
||||
"tokenInvalid": "Votre jeton d'authentification est invalide.",
|
||||
"tokenMissing": "Aucun jeton d'authentification trouvé.",
|
||||
"authFailed": "Échec de l'authentification.",
|
||||
"description": "Veuillez vous ré-authentifier pour continuer à utiliser Auto Claude.",
|
||||
"taskAffected": "Tâche affectée",
|
||||
"technicalDetails": "Détails techniques",
|
||||
"goToSettings": "Aller aux paramètres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import type {
|
||||
SessionDateRestoreResult,
|
||||
RateLimitInfo,
|
||||
SDKRateLimitInfo,
|
||||
AuthFailureInfo,
|
||||
RetryWithProfileRequest,
|
||||
CreateTerminalWorktreeRequest,
|
||||
TerminalWorktreeConfig,
|
||||
@@ -297,6 +298,8 @@ export interface ElectronAPI {
|
||||
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<ClaudeProfile | null>>;
|
||||
/** Listen for SDK/CLI rate limit events (non-terminal) */
|
||||
onSDKRateLimit: (callback: (info: SDKRateLimitInfo) => void) => () => void;
|
||||
/** Listen for auth failure events (401 errors requiring re-authentication) */
|
||||
onAuthFailure: (callback: (info: AuthFailureInfo) => void) => () => void;
|
||||
/** Retry a rate-limited operation with a different profile */
|
||||
retryWithProfile: (request: RetryWithProfileRequest) => Promise<IPCResult>;
|
||||
|
||||
|
||||
@@ -135,6 +135,28 @@ export interface SDKRateLimitInfo {
|
||||
swapReason?: 'proactive' | 'reactive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication failure information for SDK/CLI operations.
|
||||
* Emitted when Claude CLI encounters a 401 or other auth error,
|
||||
* indicating the token needs to be refreshed via re-authentication.
|
||||
*/
|
||||
export interface AuthFailureInfo {
|
||||
/** The profile ID that failed to authenticate */
|
||||
profileId: string;
|
||||
/** The profile name for display */
|
||||
profileName?: string;
|
||||
/** Type of auth failure */
|
||||
failureType: 'missing' | 'invalid' | 'expired' | 'unknown';
|
||||
/** User-friendly message describing the failure */
|
||||
message: string;
|
||||
/** Original error message from the process output */
|
||||
originalError?: string;
|
||||
/** Task ID if applicable (for task-related auth failures) */
|
||||
taskId?: string;
|
||||
/** When detected (Note: serialized as ISO string over IPC) */
|
||||
detectedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to retry a rate-limited operation with a different profile
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user