import React, { useMemo, useState } from 'react'; import { themedColor } from '../../theme'; import { ActivityIndicator, Linking, Pressable, Text, TextInput, View } from 'react-native'; import { CheckCircle2, KeyRound, Package, Plug, ShieldAlert } from 'lucide-react-native'; import { ConnectorBrandIcon, hasConnectorBrandIcon } from '../connectors/connectorBrandIcons'; import { connectorRequiresConnectionConfig } from '../connectors/connectorCatalog'; import { conversationStyles } from '../conversation/conversationStyles'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { getSequenceSignupUrl } from './sequenceSignup'; import { getPackageActionMode } from './packageUtils'; import { buildSkillInstallApprovalPayload, getSkillInstallRef } from './skillInstallApproval'; import type { SkillInstallRef } from './skillInstallApproval'; import { isRetiredPaymentsByWixApproval } from '../../toolWidgets/skillsAndConnectionsUtils'; import type { SuperagentAgent, SuperagentConnector, SuperagentConnectorActionInput, SuperagentConversation, SuperagentToolCall, } from '../../types'; type SubmitToolCallInput = ( toolCallId: string, approve: boolean, extraUserInput?: unknown, originRequestId?: string, ) => Promise; type ApprovalKind = 'channel' | 'connector' | 'customConnector' | 'package' | 'payment' | 'secrets' | 'skill' | 'guard' | 'generic'; type SecretSchemaItem = { description?: string; name: string; }; export function ToolApprovalCard({ agent, availableConnectors, onConnectConnector, submitToolCallInput, toolCall, }: { agent: SuperagentAgent; availableConnectors?: SuperagentConnector[]; // Accepted for backwards-compatible prop plumbing but intentionally unused: // the card only renders for tool calls still awaiting input (see // isApprovalRequest), so an approval is actionable regardless of position. isLastAssistantMessage?: boolean; onConnectConnector?: (input: SuperagentConnectorActionInput) => Promise | boolean | string | void; submitToolCallInput?: SubmitToolCallInput; toolCall: SuperagentToolCall; }) { const bi = useAgentBi(); const args = useMemo(() => getToolArgs(toolCall), [toolCall]); const objectArgs = args && typeof args !== 'string' ? args : null; const isRetiredPayment = isRetiredPaymentsByWixApproval(toolCall.name, objectArgs); const guardData = useMemo(() => getGuardData(toolCall.results), [toolCall.results]); const kind = getApprovalKind(toolCall, guardData); const connectorId = getStringArg(args, ['integration_type', 'connector_id', 'provider']); const connector = availableConnectors?.find((item) => item.id === connectorId); const connectorName = connector?.name || formatConnectorName(connectorId); const paymentProvider = getStringArg(args, ['provider']); const paymentName = getPaymentName(paymentProvider || toolCall.name); const channelName = toolCall.name === 'setup_telegram_connection' ? 'Telegram' : 'Slack'; const customConnectorName = getStringArg(args, ['name', 'integration_type']) || 'connector'; const scopes = getStringArrayArg(args, 'scopes'); const skillRef = kind === 'skill' ? getSkillInstallRef(args) : null; const reason = kind === 'skill' ? (skillRef?.description ?? '') : getStringArg(args, ['reason', 'description', 'message']); const packages = getPackageSpecs(args); const secretSchema = getSecretSchema(args); const signupUrl = getSequenceSignupUrl(toolCall.name); const displayName = kind === 'payment' ? (isRetiredPayment ? 'Payments by Wix' : paymentName) : kind === 'channel' ? channelName : kind === 'customConnector' ? formatConnectorName(customConnectorName) : kind === 'skill' ? (skillRef?.displayName || 'skill') : connectorName; const packageMode = kind === 'package' ? getPackageActionMode(packages) : null; const title = isRetiredPayment ? 'Payments by Wix is no longer available' : getApprovalTitle(kind, displayName, packageMode); const body = isRetiredPayment ? 'This pending installation request is no longer supported. Dismiss it to continue.' : getApprovalBody(kind, reason, guardData, displayName, packageMode); const primaryLabel = getPrimaryLabel(kind, displayName); const rejectLabel = isRetiredPayment ? 'Dismiss' : getRejectLabel(kind); const canAct = Boolean(toolCall.id && submitToolCallInput); // Track WHICH action is in flight so the spinner shows on the button the user // actually pressed — a single boolean lit up the primary (Install) spinner // even when the reject/dismiss action was the one submitting. const [pendingAction, setPendingAction] = useState<'approve' | 'reject' | null>(null); const isSubmitting = pendingAction !== null; const [error, setError] = useState(null); const [formValues, setFormValues] = useState>({ connectorName: customConnectorName === 'connector' ? '' : formatConnectorName(customConnectorName), scopes: scopes.join('\n'), }); const [secretValues, setSecretValues] = useState>({}); const [showKeyInput, setShowKeyInput] = useState(false); const needsSecrets = kind === 'secrets' && secretSchema.length > 0; const sequenceAwaitingReveal = needsSecrets && Boolean(signupUrl) && !showKeyInput; const secretsComplete = !needsSecrets || secretSchema.every((secret) => secretValues[secret.name]?.trim()); const channelComplete = kind !== 'channel' || isChannelFormComplete(toolCall.name, formValues); const customConnectorComplete = kind !== 'customConnector' || Boolean(formValues.connectorName?.trim() && formValues.clientId?.trim() && formValues.clientSecret?.trim()); // Connectors needing extra connection_config (e.g. subdomain) can't complete // OAuth from native — the backend initiate rejects them — so block approval here // and steer to the web app, matching the connectors drawer. Skip stays enabled. // Fall back to the catalog: listConnectors drops already-connected integrations // from availableConnectors, so an already-connected config-required connector // (scope upgrade / force_reconnect) would otherwise have connector === undefined // and slip past this gate, then fail backend validation for missing connection_config. const connectorNeedsConfig = kind === 'connector' && (Boolean(connector?.requiresConnectionConfig) || connectorRequiresConnectionConfig(connectorId)); // BYO_SHARED connectors arrive as an OrganizationConnector connector_id (no // integration_type). The native personal-agent facade has no connector_id field // and requires a real integration type, so the OAuth can only fail — block it // here too and steer to the web app. const isByoConnector = kind === 'connector' && !getStringArg(args, ['integration_type']) && Boolean(getStringArg(args, ['connector_id'])); // register_workspace_connector for an integration needing connection_config // (e.g. subdomain) can't succeed here: this card only collects id/secret/name/ // scopes, not connection_config, so the backend's get_validated_connection_config // rejects it. Block it and steer to the web app, like the config-required OAuth // path above. Skip stays enabled. const customConnectorNeedsConfig = kind === 'customConnector' && connectorRequiresConnectionConfig(getStringArg(args, ['integration_type', 'name'])); const connectorUnsupportedHere = connectorNeedsConfig || isByoConnector || customConnectorNeedsConfig; // Without a namespace/slug reference an approve would reach the backend as a // decline (see skillInstallApproval.ts), so block it instead. const skillComplete = kind !== 'skill' || Boolean(skillRef); const disablePrimary = !canAct || isSubmitting || !secretsComplete || !channelComplete || !customConnectorComplete || !skillComplete || connectorUnsupportedHere || sequenceAwaitingReveal; const submitApproval = async () => { if (!toolCall.id || !submitToolCallInput) return; setPendingAction('approve'); setError(null); try { let connectionId: string | undefined; if (kind === 'connector' && onConnectConnector && connectorId) { const connected = await onConnectConnector({ accessMode: 'full_access', agentId: agent.id, connectorId, // Honor the agent's force_reconnect arg (e.g. "switch accounts") so the // initiate route shows the provider/account picker instead of // short-circuiting as already-authorized — matches the web flow. forceReconnect: getBooleanArg(args, ['force_reconnect']), scopes: scopes.length > 0 ? scopes : connector?.scopes, }); if (connected === false) { throw new Error(`${connectorName} was not connected.`); } // onConnectConnector returns the connection_id (string) on success; the // backend verifies it against the just-created AppIntegration before // marking the tool successful, matching the web flow. if (typeof connected === 'string') { connectionId = connected; } } const result = await submitToolCallInput(toolCall.id, true, getApprovalPayload(kind, secretValues, paymentProvider, toolCall.name, formValues, connectionId, skillRef)); if (result === null) { // The conversation client isn't ready yet (still initializing), so nothing // reached the server. Surface it instead of silently no-opping. throw new Error('The conversation is still loading. Try again in a moment.'); } if (kind === 'skill') void bi.trackEditor('Skill Install', { decision: 'approved', skill_name: skillRef?.displayName }); else void bi.trackEditor('Tool Approval', { kind, decision: 'approved' }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to approve this request.'); } finally { setPendingAction(null); } }; const rejectApproval = async () => { if (!toolCall.id || !submitToolCallInput) return; setPendingAction('reject'); setError(null); try { const result = await submitToolCallInput(toolCall.id, false, {}); if (result === null) { throw new Error('The conversation is still loading. Try again in a moment.'); } if (kind === 'skill') void bi.trackEditor('Skill Install', { decision: 'dismissed', skill_name: skillRef?.displayName }); else void bi.trackEditor('Tool Approval', { kind, decision: 'rejected' }); } catch (submitError) { setError(submitError instanceof Error ? submitError.message : 'Unable to reject this request.'); } finally { setPendingAction(null); } }; return ( {title} Waiting For User Input {sequenceAwaitingReveal ? ( Connect your Sequence account to continue. ) : body ? ( {body} ) : null} {connectorUnsupportedHere ? ( Connect {connectorName} from the web app — it isn't supported in the app yet. ) : null} {kind === 'connector' && scopes.length > 0 ? ( Requested access {scopes.slice(0, 4).map((scope) => ( {scope} ))} {scopes.length > 4 ? ( +{scopes.length - 4} ) : null} ) : null} {kind === 'package' && packages.length > 0 ? ( {packages.map((packageSpec) => ( {packageSpec.action === 'uninstall' ? `− ${packageSpec.label}` : packageSpec.label} ))} ) : null} {needsSecrets && sequenceAwaitingReveal ? ( { if (signupUrl) void Linking.openURL(signupUrl).catch(() => {}); }} style={conversationStyles.toolApprovalPrimaryButton} > Log in / Sign up to Sequence setShowKeyInput(true)}> I already have an API key ) : null} {needsSecrets && !sequenceAwaitingReveal ? ( {secretSchema.map((secret) => ( setSecretValues((current) => ({ ...current, [secret.name]: value }))} placeholder={`Enter ${secret.name}`} secure value={secretValues[secret.name] ?? ''} /> ))} ) : null} {kind === 'channel' ? ( {toolCall.name === 'setup_telegram_connection' ? ( setFormValues((current) => ({ ...current, botToken: extractTelegramToken(value) }))} placeholder="123456789:ABC..." secure value={formValues.botToken ?? ''} /> ) : ( <> setFormValues((current) => ({ ...current, botToken: value }))} placeholder="xoxb-..." secure value={formValues.botToken ?? ''} /> setFormValues((current) => ({ ...current, signingSecret: value }))} placeholder="Enter signing secret" secure value={formValues.signingSecret ?? ''} /> )} ) : null} {kind === 'customConnector' ? ( setFormValues((current) => ({ ...current, connectorName: value }))} placeholder="Calendar" value={formValues.connectorName ?? ''} /> setFormValues((current) => ({ ...current, clientId: value }))} placeholder="OAuth client id" value={formValues.clientId ?? ''} /> setFormValues((current) => ({ ...current, clientSecret: value }))} placeholder="OAuth client secret" secure value={formValues.clientSecret ?? ''} /> setFormValues((current) => ({ ...current, scopes: value }))} placeholder="https://www.googleapis.com/auth/calendar.readonly" value={formValues.scopes ?? ''} /> ) : null} {canAct ? ( {!isRetiredPayment && !sequenceAwaitingReveal ? ( {pendingAction === 'approve' ? : } {pendingAction === 'approve' ? 'Working...' : primaryLabel} ) : null} {pendingAction === 'reject' ? ( ) : ( {sequenceAwaitingReveal ? 'Skip' : rejectLabel} )} ) : ( Approval controls are not available here. )} {error ? {error} : null} ); } function ApprovalIcon({ connector, connectorId, kind, }: { connector?: SuperagentConnector; connectorId: string; kind: ApprovalKind; }) { if (kind === 'connector' && connectorId && hasConnectorBrandIcon(connectorId)) { return ( ); } const iconColor = kind === 'guard' ? '#FDE047' : '#F4F4F5'; const fallbackLabel = connector?.iconFallbackLabel || connectorId.slice(0, 2).toUpperCase(); if (kind === 'connector' && fallbackLabel) { return ( {fallbackLabel} ); } return ( {kind === 'package' || kind === 'skill' ? : null} {kind === 'payment' ? : null} {kind === 'secrets' ? : null} {kind === 'guard' ? : null} {kind === 'generic' ? : null} ); } function ApprovalInput({ canAct, help, isSubmitting, label, multiline, onChange, placeholder, secure, value, }: { canAct: boolean; help?: string; isSubmitting: boolean; label: string; multiline?: boolean; onChange: (value: string) => void; placeholder: string; secure?: boolean; value: string; }) { return ( {label} {help ? {help} : null} ); } function getApprovalKind(toolCall: SuperagentToolCall, guardData: Record | null): ApprovalKind { if (guardData) return 'guard'; if (toolCall.name === 'setup_slack_connection' || toolCall.name === 'setup_telegram_connection') return 'channel'; if (toolCall.name === 'set_app_user_connector' || toolCall.name === 'register_workspace_connector') return 'customConnector'; if (toolCall.name === 'request_oauth_authorization') return 'connector'; if (toolCall.name === 'install_npm_package') return 'package'; if (toolCall.name === 'suggest_payments_installation') return 'payment'; if (toolCall.name === 'request_secrets' || toolCall.name === 'set_secrets' || toolCall.name === 'sequence_connect') return 'secrets'; if (toolCall.name === 'suggest_skill_installation') return 'skill'; return 'generic'; } function getApprovalTitle(kind: ApprovalKind, displayName: string, packageMode?: 'install' | 'uninstall' | 'mixed' | null) { if (kind === 'channel') return `Connect ${displayName}`; if (kind === 'connector') return `Connect to ${displayName}`; if (kind === 'customConnector') return `Set up ${displayName}`; if (kind === 'payment') return `Connect ${displayName}`; if (kind === 'package') { if (packageMode === 'uninstall') return 'Approve package removal'; if (packageMode === 'mixed') return 'Approve package changes'; return 'Approve package install'; } if (kind === 'secrets') return 'Provide secrets'; if (kind === 'skill') return `Install ${displayName}`; if (kind === 'guard') return 'Approve protected change'; return 'Approval required'; } function getApprovalBody( kind: ApprovalKind, reason: string, guardData: Record | null, displayName: string, packageMode?: 'install' | 'uninstall' | 'mixed' | null, ) { if (kind === 'channel') return reason || `Paste your ${displayName} credentials to connect this channel.`; if (kind === 'connector') return reason || `${displayName} needs authorization before this agent can continue.`; if (kind === 'customConnector') return reason || 'Provide OAuth credentials for this custom connector.'; if (kind === 'payment') return reason || `Approve setting up ${displayName} for this app.`; if (kind === 'package') { if (reason) return reason; if (packageMode === 'uninstall') return 'Approve removing these packages from the app.'; if (packageMode === 'mixed') return 'Approve these package changes for the app.'; return 'Approve installing the packages required for this step.'; } if (kind === 'secrets') return reason || 'Enter the requested secret values to continue.'; if (kind === 'skill') return reason || `Approve installing ${displayName} from the skill store.`; if (kind === 'guard') return String(guardData?.reason || 'Review and approve this protected change to continue.'); return reason || 'Review this request to continue.'; } function getPrimaryLabel(kind: ApprovalKind, displayName: string) { if (kind === 'channel' || kind === 'connector') return `Connect ${displayName}`; if (kind === 'customConnector') return 'Connect'; if (kind === 'payment') return 'Connect'; if (kind === 'secrets') return 'Submit'; if (kind === 'skill') return 'Install'; return 'Approve'; } function getRejectLabel(kind: ApprovalKind) { if (kind === 'connector' || kind === 'channel') return 'Skip'; if (kind === 'skill') return 'Dismiss'; return 'Reject'; } function getApprovalPayload( kind: ApprovalKind, secretValues: Record, paymentProvider: string, toolName: string, formValues: Record, connectionId?: string, skillRef?: SkillInstallRef | null, ) { if (kind === 'channel' && toolName === 'setup_telegram_connection') { // Empty string routes to the managed one-click bot flow on the backend. return { bot_token: formValues.botToken?.trim() ?? '' }; } if (kind === 'channel') { return { bot_token: formValues.botToken, signing_secret: formValues.signingSecret }; } if (kind === 'customConnector') { return { client_id: formValues.clientId?.trim(), client_secret: formValues.clientSecret?.trim(), name: formValues.connectorName?.trim(), scopes: splitLines(formValues.scopes), }; } if (kind === 'secrets') return { secrets: secretValues }; if (kind === 'payment' && paymentProvider === 'wix_payments') return { tos_accepted: true }; if (kind === 'connector') return connectionId ? { connection_id: connectionId } : {}; if (kind === 'skill' && skillRef) return buildSkillInstallApprovalPayload(skillRef); return {}; } function getToolArgs(toolCall: SuperagentToolCall): Record | string | null { const raw = toolCall.arguments_string ?? toolCall.arguments ?? null; if (!raw) return null; if (typeof raw !== 'string') return raw as Record; try { const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' ? parsed as Record : raw; } catch { return raw; } } function getStringArg(args: Record | string | null, keys: string[]) { if (!args || typeof args === 'string') return ''; for (const key of keys) { const value = args[key]; if (typeof value === 'string' && value.trim()) return value.trim(); } return ''; } function getBooleanArg(args: Record | string | null, keys: string[]): boolean { if (!args || typeof args === 'string') return false; for (const key of keys) { const value = args[key]; if (typeof value === 'boolean') return value; if (value === 'true') return true; } return false; } function getStringArrayArg(args: Record | string | null, key: string) { if (!args || typeof args === 'string') return []; const value = args[key]; if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); if (typeof value === 'string' && value.trim()) return [value.trim()]; return []; } type PackageAction = 'install' | 'uninstall'; interface PackageSpec { label: string; action: PackageAction; } function getPackageSpecs(args: Record | string | null): PackageSpec[] { if (!args || typeof args === 'string') return []; const packages = args.packages; if (Array.isArray(packages)) { return packages .map((packageEntry): PackageSpec | null => { if (packageEntry && typeof packageEntry === 'object') { const entry = packageEntry as Record; const name = String(entry.name || '').trim(); if (!name) return null; const action: PackageAction = entry.action === 'uninstall' ? 'uninstall' : 'install'; const semver = String(entry.semver || '').trim(); const label = action === 'install' && semver ? `${name}@${semver}` : name; return { label, action }; } const label = String(packageEntry || '').trim(); return label ? { label, action: 'install' } : null; }) .filter((spec): spec is PackageSpec => spec !== null); } if (typeof packages === 'string' && packages.trim()) { return packages .split(packages.includes(',') ? ',' : /\s+/) .map((item) => item.trim()) .filter(Boolean) .map((label) => ({ label, action: 'install' as const })); } const packageName = getStringArg(args, ['package_name']); const version = getStringArg(args, ['version']); if (!packageName) return []; return [{ label: version ? `${packageName}@${version}` : packageName, action: 'install' }]; } function getSecretSchema(args: Record | string | null): SecretSchemaItem[] { if (!args || typeof args === 'string') return []; let schema = args.secrets_schema; if (typeof schema === 'string') { try { schema = JSON.parse(schema); } catch { schema = []; } } if (!Array.isArray(schema)) return []; const secretItems: SecretSchemaItem[] = []; schema.forEach((item) => { if (!item || typeof item !== 'object') return; const record = item as Record; const name = String(record.secretName || record.name || record.key || '').trim(); if (!name) return; secretItems.push({ description: typeof record.description === 'string' ? record.description : undefined, name, }); }); return secretItems; } function isChannelFormComplete(toolName: string, formValues: Record) { if (toolName === 'setup_telegram_connection') { const token = formValues.botToken?.trim() || ''; // An empty token is valid: the backend creates a managed one-click bot in that // case. A token that *is* provided must be a well-formed BotFather token. return token === '' || /^\d+:[A-Za-z0-9_-]{35,}$/.test(token); } return Boolean( formValues.botToken?.trim().startsWith('xoxb-') && (formValues.botToken?.trim().length ?? 0) >= 20 && (formValues.signingSecret?.trim().length ?? 0) >= 16, ); } function extractTelegramToken(value: string) { const trimmed = value.trim(); if (/^\d+:[A-Za-z0-9_-]{35,}$/.test(trimmed)) return trimmed; const match = trimmed.match(/\d{5,15}:[A-Za-z0-9_-]{35,50}/); return match?.[0] ?? value; } function splitLines(value?: string) { return (value || '') .split('\n') .map((line) => line.trim()) .filter(Boolean); } function getGuardData(result: unknown) { if (!result) return null; const value = typeof result === 'string' ? parseJson(result) : result; if (value && typeof value === 'object' && 'guard' in value) return value as Record; return null; } function parseJson(value: string) { try { return JSON.parse(value); } catch { return null; } } function formatConnectorName(connectorId: string) { if (!connectorId) return 'connector'; return connectorId .split(/[_-]/) .filter(Boolean) .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) .join(' '); } function getPaymentName(provider: string) { if (provider === 'wix_payments') return 'Base44 Payments'; if (provider === 'stripe') return 'Stripe'; return 'payments'; }