import { AblyCliTerminal, type AblyCliTerminalHandle } from "@ably/react-web-cli"; import { useCallback, useEffect, useRef, useState } from "react"; import { Key, Settings, Shield } from "lucide-react"; import "./App.css"; import { CliDrawer } from "./components/CliDrawer"; import { AuthSettings } from "./components/AuthSettings"; import { AuthScreen } from "./components/AuthScreen"; // Extend Window interface for CLI-specific properties interface CliWindow extends Window { __ABLY_CLI_CI_AUTH_TOKEN__?: string; __ABLY_CLI_CI_MODE__?: string; _sessionId?: string; } // Default WebSocket URL - use public endpoint for production, localhost for development const DEFAULT_PRODUCTION_WEBSOCKET_URL = "wss://web-cli-terminal.ably.com"; const DEFAULT_DEVELOPMENT_WEBSOCKET_URL = "wss://web-cli-terminal.ably-dev.com"; // Get WebSocket URL from query parameters only const getWebSocketUrl = () => { const urlParams = new URLSearchParams(window.location.search); const serverParam = urlParams.get("serverUrl"); if (serverParam) { console.log(`[App.tsx] Found serverUrl param: ${serverParam}`); return serverParam; } return isRunningCIMode() ? DEFAULT_DEVELOPMENT_WEBSOCKET_URL : DEFAULT_PRODUCTION_WEBSOCKET_URL; }; const isRunningCIMode = (): boolean => { return (window as CliWindow).__ABLY_CLI_CI_MODE__ === "true"; } // Get CI auth token if available const getCIAuthToken = (): string | undefined => { return (window as CliWindow).__ABLY_CLI_CI_AUTH_TOKEN__; }; // Get signed credentials from various sources const getInitialCredentials = () => { const urlParams = new URLSearchParams(window.location.search); // Get the domain from the WebSocket URL for scoping const wsUrl = getWebSocketUrl(); const wsDomain = new URL(wsUrl).host; // Check if we should clear credentials (for testing) if (urlParams.get('clearCredentials') === 'true') { // Clear new signed format localStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`); localStorage.removeItem(`ably.web-cli.signature.${wsDomain}`); localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.signature.${wsDomain}`); // Also clear old format (migration) localStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`); localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`); // Remove the clearCredentials param from URL const cleanUrl = new URL(window.location.href); cleanUrl.searchParams.delete('clearCredentials'); window.history.replaceState(null, '', cleanUrl.toString()); } // Check query parameters (only in development/test environments) const qsSignedConfig = urlParams.get('signedConfig'); const qsSignature = urlParams.get('signature'); if (qsSignedConfig && qsSignature) { // Security check: only allow query param auth in development/test environments const isProduction = import.meta.env.PROD && !window.location.hostname.includes('localhost') && !window.location.hostname.includes('127.0.0.1'); if (isProduction) { console.error('[App] Security Warning: Signed credentials in query parameters are not allowed in production.'); console.error('[App] Credentials contain API keys that can leak through browser history, server logs, and shared URLs.'); // Clear the sensitive query parameters from the URL const cleanUrl = new URL(window.location.href); cleanUrl.searchParams.delete('signedConfig'); cleanUrl.searchParams.delete('signature'); cleanUrl.searchParams.delete('clearCredentials'); window.history.replaceState(null, '', cleanUrl.toString()); // Don't use these credentials - fall through to storage check } else { console.log('[App] Using signed config from query parameters (dev/test mode)'); return { signedConfig: qsSignedConfig, signature: qsSignature, source: 'query' as const }; } } // Check localStorage for persisted signed credentials (if user chose to remember) const rememberCredentials = localStorage.getItem(`ably.web-cli.rememberCredentials.${wsDomain}`) === 'true'; if (rememberCredentials) { const storedSignedConfig = localStorage.getItem(`ably.web-cli.signedConfig.${wsDomain}`); const storedSignature = localStorage.getItem(`ably.web-cli.signature.${wsDomain}`); if (storedSignedConfig && storedSignature) { console.log('[App] Using signed config from localStorage'); return { signedConfig: storedSignedConfig, signature: storedSignature, source: 'localStorage' as const }; } } // Check sessionStorage for session-only signed credentials const sessionSignedConfig = sessionStorage.getItem(`ably.web-cli.signedConfig.${wsDomain}`); const sessionSignature = sessionStorage.getItem(`ably.web-cli.signature.${wsDomain}`); if (sessionSignedConfig && sessionSignature) { console.log('[App] Using signed config from sessionStorage'); return { signedConfig: sessionSignedConfig, signature: sessionSignature, source: 'session' as const }; } // Check for old format credentials (migration) const oldApiKey = localStorage.getItem(`ably.web-cli.apiKey.${wsDomain}`) || sessionStorage.getItem(`ably.web-cli.apiKey.${wsDomain}`); if (oldApiKey) { console.warn('[App] Found old credential format. Please re-authenticate with signed credentials.'); // Clear old format localStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`); localStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.apiKey.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.accessToken.${wsDomain}`); } return { signedConfig: undefined, signature: undefined, source: 'none' as const }; }; function App() { // Read initial mode from URL, default to fullscreen const initialMode = new URLSearchParams(window.location.search).get("mode") as ("fullscreen" | "drawer") || "fullscreen"; type TermStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; const [connectionStatus, setConnectionStatus] = useState('disconnected'); const [displayMode, setDisplayMode] = useState<"fullscreen" | "drawer">(initialMode); const [showAuthSettings, setShowAuthSettings] = useState(false); // Initialize signed credentials const initialCreds = getInitialCredentials(); const [signedConfig, setSignedConfig] = useState(initialCreds.signedConfig); const [signature, setSignature] = useState(initialCreds.signature); const [isAuthenticated, setIsAuthenticated] = useState(Boolean(initialCreds.signedConfig && initialCreds.signature)); const [authSource, setAuthSource] = useState(initialCreds.source); // Get the URL and domain early for use in state initialization const currentWebsocketUrl = getWebSocketUrl(); const wsDomain = new URL(currentWebsocketUrl).host; const [rememberCredentials, setRememberCredentials] = useState(localStorage.getItem(`ably.web-cli.rememberCredentials.${wsDomain}`) === 'true'); // Store the latest sessionId globally for E2E tests / debugging const handleSessionId = useCallback((id: string) => { console.log(`[App] Received sessionId: ${id}`); (window as CliWindow)._sessionId = id; // Expose for Playwright }, []); const handleConnectionChange = useCallback((status: TermStatus) => { console.log("Connection Status:", status); setConnectionStatus(status); }, []); const handleSessionEnd = useCallback((reason: string) => { console.log("Session ended:", reason); }, []); // Handle authentication const handleAuthenticate = useCallback(async (newApiKey: string, remember?: boolean) => { try { // Call /api/sign endpoint to get signed config const response = await fetch('/api/sign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: newApiKey, bypassRateLimit: false }) }); if (!response.ok) { const error = await response.json(); console.error('[App] Failed to sign credentials:', error); throw new Error(error.error || 'Failed to sign credentials'); } const { signedConfig: newSignedConfig, signature: newSignature } = await response.json(); // Clear any existing session data when credentials change (domain-scoped) sessionStorage.removeItem(`ably.cli.sessionId.${wsDomain}`); sessionStorage.removeItem(`ably.cli.secondarySessionId.${wsDomain}`); sessionStorage.removeItem(`ably.cli.isSplit.${wsDomain}`); setSignedConfig(newSignedConfig); setSignature(newSignature); setIsAuthenticated(true); setShowAuthSettings(false); // Determine if we should remember based on parameter or current state const shouldRemember = remember !== undefined ? remember : rememberCredentials; if (shouldRemember) { // Store in localStorage for persistence (domain-scoped) localStorage.setItem(`ably.web-cli.signedConfig.${wsDomain}`, newSignedConfig); localStorage.setItem(`ably.web-cli.signature.${wsDomain}`, newSignature); localStorage.setItem(`ably.web-cli.rememberCredentials.${wsDomain}`, 'true'); setAuthSource('localStorage'); } else { // Store only in sessionStorage (domain-scoped) sessionStorage.setItem(`ably.web-cli.signedConfig.${wsDomain}`, newSignedConfig); sessionStorage.setItem(`ably.web-cli.signature.${wsDomain}`, newSignature); // Clear from localStorage if it was there (domain-scoped) localStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`); localStorage.removeItem(`ably.web-cli.signature.${wsDomain}`); localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`); setAuthSource('session'); } setRememberCredentials(shouldRemember); } catch (error) { console.error('[App] Authentication error:', error); throw error; } }, [rememberCredentials, wsDomain]); // Handle auth settings save const handleAuthSettingsSave = useCallback(async (newApiKey: string, remember: boolean) => { if (newApiKey) { await handleAuthenticate(newApiKey, remember); } else { // Clear all credentials - go back to auth screen (domain-scoped) sessionStorage.removeItem(`ably.cli.sessionId.${wsDomain}`); sessionStorage.removeItem(`ably.cli.secondarySessionId.${wsDomain}`); sessionStorage.removeItem(`ably.cli.isSplit.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`); sessionStorage.removeItem(`ably.web-cli.signature.${wsDomain}`); localStorage.removeItem(`ably.web-cli.signedConfig.${wsDomain}`); localStorage.removeItem(`ably.web-cli.signature.${wsDomain}`); localStorage.removeItem(`ably.web-cli.rememberCredentials.${wsDomain}`); setSignedConfig(undefined); setSignature(undefined); setIsAuthenticated(false); setShowAuthSettings(false); setRememberCredentials(false); } }, [handleAuthenticate, wsDomain]); // Effect to update URL when displayMode changes useEffect(() => { const urlParams = new URLSearchParams(window.location.search); if (urlParams.get("mode") !== displayMode) { urlParams.set("mode", displayMode); window.history.replaceState({}, '', `${window.location.pathname}?${urlParams.toString()}`); } }, [displayMode]); // Prepare the terminal component instance to pass it down const termRef = useRef(null); const TerminalInstance = useCallback(() => ( isAuthenticated && signedConfig && signature ? ( ) : null ), [isAuthenticated, signedConfig, signature, handleConnectionChange, handleSessionEnd, handleSessionId, currentWebsocketUrl]); // Show auth screen if not authenticated if (!isAuthenticated) { return ; } return (
{/* Updated header with auth button */}
Ably Web CLI Terminal
Status: {connectionStatus} Server: {currentWebsocketUrl}
{/* Main content */} {displayMode === 'fullscreen' ? (
) : ( )} {/* Auth settings modal */} setShowAuthSettings(false)} onSave={handleAuthSettingsSave} currentSignedConfig={signedConfig} rememberCredentials={rememberCredentials} />
); } export default App;