mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
honor2030
Steven Enamakel
parent
c8cd15be91
commit
9400f77364
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1095,6 +1095,8 @@ const zhCN: TranslationMap = {
|
||||
|
||||
// Composio: miscellaneous
|
||||
'composio.statusUnavailable': '状态不可用',
|
||||
'composio.authExpired': '授权已过期',
|
||||
'composio.reconnect': '重新连接',
|
||||
'composio.envVarOverrides': '已设置,将覆盖此设置。',
|
||||
|
||||
// Memory: day-of-week labels for heatmap
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user