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:
Andy
2026-01-20 18:08:31 +01:00
committed by GitHub
parent c57534c3fe
commit 317d5e9488
13 changed files with 285 additions and 1 deletions
+1
View File
@@ -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),
+4
View File
@@ -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>
);
}
+17 -1
View File
@@ -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"
}
}
}
+3
View File
@@ -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
*/