Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 103 additions & 14 deletions components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export const Chat = () => {
const [interactionMessage, setInteractionMessage] = useState(null);
const webSocketRef = useRef<WebSocket | null>(null);
const webSocketConnectedRef = useRef(false);
const oauthPopupCancelledRef = useRef(false);
const webSocketModeRef = useRef(
sessionStorage.getItem('webSocketMode') === 'false' ? false : webSocketMode
);
Expand Down Expand Up @@ -492,8 +493,17 @@ export const Chat = () => {
}
}, [intermediateStepOverride]);

const persistOAuthPendingMessage = () => {
const conversation = selectedConversationRef.current;
if (!conversation) return;
const lastUserMessage = fetchLastMessage({ messages: conversation.messages, role: 'user' });
if (!lastUserMessage) return;
sessionStorage.setItem('oauth_pending_message', JSON.stringify(lastUserMessage));
sessionStorage.setItem('oauth_pending_conversation_id', conversation.id);
};

/**
* Handles OAuth consent flow by opening popup window
* Handles OAuth consent flow by opening a popup window or navigating in the same tab
*/
const handleOAuthConsent = (message: WebSocketInbound) => {
if (!isSystemInteractionMessage(message)) return false;
Expand All @@ -507,17 +517,23 @@ export const Chat = () => {
toast.error('OAuth URL validation failed.');
return false;
}

const popup = window.open(
oauthUrl,
'oauth-popup',
'width=600,height=700,scrollbars=yes,resizable=yes,noopener,noreferrer'
);
const handleOAuthComplete = (event: MessageEvent) => {
if (popup && !popup.closed) popup.close();
window.removeEventListener('message', handleOAuthComplete);
};
window.addEventListener('message', handleOAuthComplete);

const shouldUsePopup = !message.content?.use_redirect;
if (shouldUsePopup) {
const popup = window.open(
oauthUrl,
'oauth-popup',
'noopener,noreferrer'
);
const handleOAuthComplete = (event: MessageEvent) => {
if (popup && !popup.closed) popup.close();
window.removeEventListener('message', handleOAuthComplete);
};
window.addEventListener('message', handleOAuthComplete);
} else {
persistOAuthPendingMessage();
window.location.href = oauthUrl;
}
}
return true;
}
Expand Down Expand Up @@ -748,8 +764,23 @@ export const Chat = () => {
if (oauthUrl) {
// Validate URL before opening to prevent Open Redirect attacks
if (isValidConsentPromptURL(oauthUrl)) {
// Open the validated OAuth URL in a new tab
window.open(oauthUrl, '_blank', 'noopener,noreferrer');
const shouldUsePopup = !message?.content?.use_redirect;
if (shouldUsePopup) {
if (oauthPopupCancelledRef.current) return;
// Open the validated OAuth URL in a new tab
const popup = window.open(oauthUrl, 'oauth-popup', 'noopener,noreferrer');
const handleOAuthComplete = (event: MessageEvent) => {
if (popup && !popup.closed) popup.close();
window.removeEventListener('message', handleOAuthComplete);
if (event.data?.type === 'AUTH_CANCELLED') {
oauthPopupCancelledRef.current = true;
}
};
window.addEventListener('message', handleOAuthComplete);
} else {
persistOAuthPendingMessage();
window.location.href = oauthUrl;
}
} else {
console.error('OAuth URL validation failed, refusing to open potentially malicious URL:', oauthUrl);
toast.error('Invalid OAuth URL received. Please contact support.');
Expand Down Expand Up @@ -831,6 +862,7 @@ export const Chat = () => {
const handleSend = useCallback(
async (message: Message, deleteCount = 0, retry = false) => {
message.id = uuidv4();
oauthPopupCancelledRef.current = false;

// Set the active user message ID for WebSocket message tracking
activeUserMessageId.current = message.id;
Expand Down Expand Up @@ -1434,6 +1466,63 @@ export const Chat = () => {
handleSend(editedMessage, deleteCount || 0);
}, [handleSend]);

// After returning from the OAuth provider, resubmit the message that triggered auth.
useEffect(() => {
const pendingMessageRaw = sessionStorage.getItem('oauth_pending_message');
const pendingConversationId = sessionStorage.getItem('oauth_pending_conversation_id');
if (!pendingMessageRaw || !pendingConversationId) return;
if (!selectedConversation || selectedConversation.id !== pendingConversationId) return;

// The success page runs at the NAT server origin, so sessionStorage is cross-origin and
// unavailable here. The flag is instead passed back as a URL query parameter.
const urlParams = new URLSearchParams(window.location.search);
const authCompleted = urlParams.get('oauth_auth_completed');
if (authCompleted) {
urlParams.delete('oauth_auth_completed');
const cleanUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
window.history.replaceState({}, '', cleanUrl);
}

sessionStorage.removeItem('oauth_pending_message');
sessionStorage.removeItem('oauth_pending_conversation_id');

// If the user pressed back without completing OAuth, show a cancellation message.
if (!authCompleted) {
const conversation = selectedConversationRef.current;
if (conversation) {
const messages = conversation.messages;
const lastMessage = messages.at(-1);
const updatedMessages = lastMessage?.role === 'assistant'
? messages.map((m, idx) =>
idx === messages.length - 1 ? updateAssistantMessage(m, 'Authorization cancelled.') : m
)
: [...messages, createAssistantMessage(undefined, undefined, 'Authorization cancelled.')];
const updatedConversation = { ...conversation, messages: updatedMessages };
const updatedConversations = conversationsRef.current.map(c =>
c.id === updatedConversation.id ? updatedConversation : c
);
updateRefsAndDispatch(updatedConversations, updatedConversation, conversation);
}
return;
}

const resume = async () => {
let pendingMessage: Message;
try {
pendingMessage = JSON.parse(pendingMessageRaw);
} catch {
return;
}
// Ensure the WebSocket is connected before calling handleSend
if (webSocketModeRef.current && !webSocketConnectedRef.current) {
await connectWebSocket();
}
// Delete the user message + empty assistant placeholder appended during original send, then resubmit.
handleSend(pendingMessage, 2);
};
resume();
}, [selectedConversation?.id]);

// Add a new effect to handle streaming state changes
useEffect(() => {
if (messageIsStreaming) {
Expand Down
5 changes: 5 additions & 0 deletions pages/api/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ const Home = (props: any) => {
dispatch({ field: 'showChatbar', value: showChatbar === 'true' });
}

const webSocketMode = sessionStorage.getItem('webSocketMode');
if (webSocketMode !== null) {
dispatch({ field: 'webSocketMode', value: webSocketMode === 'true' });
}

const enableIntermediateSteps = sessionStorage.getItem('enableIntermediateSteps');
if (enableIntermediateSteps !== null) {
dispatch({ field: 'enableIntermediateSteps', value: enableIntermediateSteps === 'true' });
Expand Down
1 change: 1 addition & 0 deletions types/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface SystemInteractionMessage extends WebSocketMessageBase {
text?: string;
timeout?: number | null;
error?: string | null;
use_redirect?: boolean;
};
thread_id?: string;
}
Expand Down