fix: surface expired Composio auth state (#1893)

Co-authored-by: honor2030 <19909783+honor2030@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
이민재
2026-05-16 20:41:20 -07:00
committed by GitHub
co-authored by honor2030 Steven Enamakel
parent c8cd15be91
commit 9400f77364
9 changed files with 152 additions and 14 deletions
@@ -223,6 +223,23 @@ describe('<ComposioConnectModal>', () => {
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
});
it('shows an expired-auth recovery state with a reconnect CTA', () => {
const connection: ComposioConnection = {
id: 'ca_expired',
toolkit: 'gmail',
status: 'EXPIRED',
};
render(
<ComposioConnectModal toolkit={mockToolkit} connection={connection} onClose={() => {}} />
);
expect(screen.getByText(/Gmail authorization expired/i)).toBeInTheDocument();
expect(screen.getByText(/Reconnect to re-enable Gmail tools/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reconnect Gmail/i })).toBeInTheDocument();
expect(screen.queryByText(/ca_expired/)).not.toBeInTheDocument();
});
// ── Connect flow → openUrl(connectUrl) ───────────────────────────
//
// Verifies the end-to-end OAuth handoff plumbing for #1710:
@@ -127,6 +127,7 @@ type Phase =
| 'authorizing'
| 'waiting'
| 'connected'
| 'expired'
| 'disconnecting'
| 'error';
@@ -156,8 +157,15 @@ export default function ComposioConnectModal({
const initialState = deriveComposioState(connection);
const initiallyConnected = initialState === 'connected';
const initiallyExpired = initialState === 'expired';
const [phase, setPhase] = useState<Phase>(
initiallyConnected ? 'connected' : initialState === 'pending' ? 'waiting' : 'idle'
initiallyConnected
? 'connected'
: initiallyExpired
? 'expired'
: initialState === 'pending'
? 'waiting'
: 'idle'
);
const [error, setError] = useState<string | null>(null);
const [connectUrl, setConnectUrl] = useState<string | null>(null);
@@ -255,6 +263,12 @@ export default function ComposioConnectModal({
setError(`Connection failed (status: ${hit.status}).`);
return;
}
if (state === 'expired') {
stopPolling();
setPhase('expired');
setError(null);
return;
}
}
} catch (err) {
// Swallow transient errors during polling — we'll retry on next tick.
@@ -481,7 +495,12 @@ export default function ComposioConnectModal({
if (e.target === e.currentTarget) onClose();
};
const headerTitle = phase === 'connected' ? `Manage ${toolkit.name}` : `Connect ${toolkit.name}`;
const headerTitle =
phase === 'connected'
? `Manage ${toolkit.name}`
: phase === 'expired'
? `Reconnect ${toolkit.name}`
: `Connect ${toolkit.name}`;
const modalContent = (
<div
@@ -653,6 +672,27 @@ export default function ComposioConnectModal({
</>
)}
{phase === 'expired' && (
<>
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-coral-800">
<div className="w-2 h-2 rounded-full bg-coral-500" />
{toolkit.name} authorization expired
</div>
<p className="mt-2 text-xs leading-relaxed text-coral-700">
Reconnect to re-enable {toolkit.name} tools. OpenHuman will keep this integration
unavailable until you refresh OAuth access.
</p>
</div>
<button
type="button"
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
Reconnect {toolkit.name}
</button>
</>
)}
{phase === 'connected' && (
<>
<div className="flex items-center gap-2 text-sm text-sage-700">
@@ -706,7 +746,9 @@ export default function ComposioConnectModal({
<button
type="button"
onClick={() => {
setPhase(initiallyConnected ? 'connected' : 'idle');
setPhase(
initiallyConnected ? 'connected' : initiallyExpired ? 'expired' : 'idle'
);
setError(null);
}}
className="w-full rounded-xl border border-stone-200 bg-white text-stone-700 text-sm font-medium py-2 hover:bg-stone-50 transition-colors">
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { type ComposioConnection, deriveComposioState } from './types';
function connection(status: string): ComposioConnection {
return { id: `ca_${status.toLowerCase()}`, toolkit: 'gmail', status };
}
describe('deriveComposioState', () => {
it('treats expired Composio auth as a first-class expired state', () => {
expect(deriveComposioState(connection('EXPIRED'))).toBe('expired');
});
it('keeps failed and generic error statuses as error', () => {
expect(deriveComposioState(connection('FAILED'))).toBe('error');
expect(deriveComposioState(connection('ERROR'))).toBe('error');
});
it('keeps active and pending statuses unchanged', () => {
expect(deriveComposioState(connection('ACTIVE'))).toBe('connected');
expect(deriveComposioState(connection('CONNECTED'))).toBe('connected');
expect(deriveComposioState(connection('PENDING'))).toBe('pending');
});
});
+9 -3
View File
@@ -12,7 +12,7 @@ export interface ComposioToolkitsResponse {
export interface ComposioConnection {
id: string;
toolkit: string;
/** Typical values: `ACTIVE`, `CONNECTED`, `PENDING`, `FAILED`. */
/** Typical values: `ACTIVE`, `CONNECTED`, `PENDING`, `FAILED`, `EXPIRED`. */
status: string;
/** ISO timestamp (backend passthrough). */
createdAt?: string;
@@ -117,7 +117,12 @@ export interface ComposioDisableTriggerResponse {
* Mirrors the `SkillConnectionStatus` shape so the same
* `UnifiedSkillCard` can render both.
*/
export type ComposioConnectionState = 'disconnected' | 'pending' | 'connected' | 'error';
export type ComposioConnectionState =
| 'disconnected'
| 'pending'
| 'connected'
| 'expired'
| 'error';
export function deriveComposioState(
connection: ComposioConnection | undefined
@@ -126,6 +131,7 @@ export function deriveComposioState(
const status = connection.status.toUpperCase();
if (status === 'ACTIVE' || status === 'CONNECTED') return 'connected';
if (status === 'PENDING' || status === 'INITIATED' || status === 'INITIALIZING') return 'pending';
if (status === 'FAILED' || status === 'ERROR' || status === 'EXPIRED') return 'error';
if (status === 'EXPIRED') return 'expired';
if (status === 'FAILED' || status === 'ERROR') return 'error';
return 'disconnected';
}
+2
View File
@@ -1137,6 +1137,8 @@ const en: TranslationMap = {
// Composio: miscellaneous
'composio.statusUnavailable': 'Status unavailable',
'composio.authExpired': 'Auth expired',
'composio.reconnect': 'Reconnect',
'composio.envVarOverrides': 'is set, it overrides this setting.',
// Memory: day-of-week labels for heatmap
+2
View File
@@ -1095,6 +1095,8 @@ const zhCN: TranslationMap = {
// Composio: miscellaneous
'composio.statusUnavailable': '状态不可用',
'composio.authExpired': '授权已过期',
'composio.reconnect': '重新连接',
'composio.envVarOverrides': '已设置,将覆盖此设置。',
// Memory: day-of-week labels for heatmap
+15 -6
View File
@@ -81,6 +81,8 @@ function composioStatusLabel(
return t('skills.connected');
case 'pending':
return t('channels.status.connecting');
case 'expired':
return t('composio.authExpired');
case 'error':
return t('common.error');
default:
@@ -94,6 +96,8 @@ function composioStatusColor(connection: ComposioConnection | undefined): string
return 'text-sage-600';
case 'pending':
return 'text-amber-600';
case 'expired':
return 'text-coral-600';
case 'error':
return 'text-coral-600';
default:
@@ -108,10 +112,12 @@ function composioSortRank(connection: ComposioConnection | undefined): number {
return 0;
case 'pending':
return 1;
case 'error':
case 'expired':
return 2;
default:
case 'error':
return 3;
default:
return 4;
}
}
@@ -141,12 +147,15 @@ function ComposioConnectorTile({
? t('skills.configure')
: state === 'pending'
? t('skills.connect')
: state === 'error'
? t('common.retry')
: t('skills.connect');
: state === 'expired'
? t('composio.reconnect')
: state === 'error'
? t('common.retry')
: t('skills.connect');
const isConnected = state === 'connected';
const isPending = state === 'pending';
const isExpired = state === 'expired';
const isError = state === 'error' || hasComposioError;
const handleClick = () => {
@@ -168,7 +177,7 @@ function ComposioConnectorTile({
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50'
: isPending
? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70'
: isError
: isExpired || isError
? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50'
: 'border-stone-200 bg-white hover:bg-stone-50'
}`}>
@@ -88,4 +88,28 @@ describe('Skills page — Composio catalog fallback', () => {
fireEvent.click(screen.getAllByRole('button', { name: 'Retry' })[0]);
expect(composioRefresh).toHaveBeenCalledTimes(1);
});
it('surfaces expired Composio auth as reconnectable from the Gmail tile', () => {
composioToolkits = ['gmail'];
composioConnectionByToolkit = new Map([
['gmail', { id: 'ca_expired', toolkit: 'gmail', status: 'EXPIRED' }],
]);
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
const integrationsSection = screen
.getByRole('heading', { name: 'Integrations' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', {
name: /Gmail.*Auth expired.*Reconnect/i,
});
expect(within(gmailTile).getByText('Auth expired')).toBeInTheDocument();
fireEvent.click(gmailTile);
expect(screen.getByText(/Gmail authorization expired/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reconnect Gmail/i })).toBeInTheDocument();
});
});
+14 -2
View File
@@ -26,6 +26,8 @@ function statusDotClass(connection: ComposioConnection | undefined): string {
return 'bg-sage-500';
case 'pending':
return 'bg-amber-500 animate-pulse';
case 'expired':
return 'bg-coral-500';
case 'error':
return 'bg-coral-500';
default:
@@ -42,6 +44,8 @@ function statusLabel(
return t('skills.connected');
case 'pending':
return t('channels.status.connecting');
case 'expired':
return t('composio.authExpired');
case 'error':
return t('common.error');
default:
@@ -55,6 +59,8 @@ function statusColor(state: ReturnType<typeof deriveComposioState>): string {
return 'text-sage-600';
case 'pending':
return 'text-amber-600';
case 'expired':
return 'text-coral-600';
case 'error':
return 'text-coral-600';
default:
@@ -147,9 +153,15 @@ const SkillsStep = ({ onNext, onBack: _onBack }: SkillsStepProps) => {
? 'border-sage-200 bg-sage-50 text-sage-700'
: gmailState === 'pending'
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-primary-200 bg-primary-50 text-primary-700'
: gmailState === 'expired'
? 'border-coral-200 bg-coral-50 text-coral-700'
: 'border-primary-200 bg-primary-50 text-primary-700'
}`}>
{gmailConnected ? t('skills.configure') : t('skills.connect')}
{gmailConnected
? t('skills.configure')
: gmailState === 'expired'
? t('composio.reconnect')
: t('skills.connect')}
</span>
</button>
)}