From b6e3efff4f6a9eda91cd0fe74027bf7902abedbb Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 21 Apr 2025 11:41:29 +0000 Subject: [PATCH 1/8] chore: remove excalidraw-patch.js script and its reference from index.html --- src/frontend/excalidraw-patch.js | 35 -------------------------------- src/frontend/index.html | 1 - 2 files changed, 36 deletions(-) delete mode 100644 src/frontend/excalidraw-patch.js diff --git a/src/frontend/excalidraw-patch.js b/src/frontend/excalidraw-patch.js deleted file mode 100644 index b8065fa..0000000 --- a/src/frontend/excalidraw-patch.js +++ /dev/null @@ -1,35 +0,0 @@ -// Patch Excalidraw to allow same-origin for all embedded content -(function() { - - // Patch at the prototype level to affect all future iframe instances - const patchIframePrototype = () => { - try { - const originalSetAttribute = HTMLIFrameElement.prototype.setAttribute; - - // Override the setAttribute method for iframes - HTMLIFrameElement.prototype.setAttribute = function(name, value) { - if (name === 'sandbox' && !value.includes('allow-same-origin')) { - value = value + ' allow-same-origin'; - console.debug("Intercepted iframe setAttribute for sandbox, added allow-same-origin"); - } - - return originalSetAttribute.call(this, name, value); - }; - - console.debug("Patched HTMLIFrameElement.prototype.setAttribute"); - } catch (e) { - console.error("Failed to patch iframe prototype:", e); - } - }; - - // Initialize immediately if document is already loaded - if (document.readyState === 'complete' || document.readyState === 'interactive') { - patchIframePrototype(); - } else { - // Otherwise wait for the DOM to be ready - window.addEventListener('DOMContentLoaded', patchIframePrototype); - } - - // Also initialize on load to be sure - window.addEventListener('load', patchIframePrototype); -})(); diff --git a/src/frontend/index.html b/src/frontend/index.html index 649859c..c9f0a91 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -21,7 +21,6 @@ window.ExcalidrawLib = ExcalidrawLib; - From 6d2900a00c7ef61c1f7e85b18b7a50ad5ca40209 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 21 Apr 2025 11:43:52 +0000 Subject: [PATCH 2/8] refactor: integrate PostHog client and remove unused components --- src/frontend/index.tsx | 18 +- src/frontend/src/CapsLockOverlay.tsx | 194 ------------------ src/frontend/src/ErrorBoundary.tsx | 64 ------ src/frontend/src/MainMenu.tsx | 184 ----------------- src/frontend/src/styles/DiscordButton.scss | 6 + src/frontend/src/styles/FeedbackButton.scss | 6 + .../src/{components => ui}/DiscordButton.tsx | 0 .../src/{components => ui}/FeedbackButton.tsx | 0 8 files changed, 16 insertions(+), 456 deletions(-) delete mode 100644 src/frontend/src/CapsLockOverlay.tsx delete mode 100644 src/frontend/src/ErrorBoundary.tsx delete mode 100644 src/frontend/src/MainMenu.tsx rename src/frontend/src/{components => ui}/DiscordButton.tsx (100%) rename src/frontend/src/{components => ui}/FeedbackButton.tsx (100%) diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index b397015..1a6509c 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -1,6 +1,9 @@ import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; + +import posthog from "./src/utils/posthog"; import { PostHogProvider } from 'posthog-js/react'; + import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { queryClient } from './src/api/queryClient'; @@ -11,12 +14,8 @@ import "./src/styles/index.scss"; import type * as TExcalidraw from "@excalidraw/excalidraw"; import App from "./src/App"; -import { AuthProvider } from "./src/auth/AuthContext"; import AuthGate from "./src/AuthGate"; -import ErrorBoundary from "./src/ErrorBoundary"; -// PostHog is automatically initialized in ./utils/posthog.ts -import "./src/utils/posthog"; declare global { interface Window { @@ -30,15 +29,7 @@ async function initApp() { const { Excalidraw } = window.ExcalidrawLib; root.render( - - - - + - , ); } diff --git a/src/frontend/src/CapsLockOverlay.tsx b/src/frontend/src/CapsLockOverlay.tsx deleted file mode 100644 index 8c585a6..0000000 --- a/src/frontend/src/CapsLockOverlay.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import './styles/CapsLockOverlay.scss'; -import { debounce } from './utils/debounce'; - -interface CapsLockOverlayProps { - containerRef: React.RefObject; -} - -const CapsLockOverlay: React.FC = ({ containerRef }) => { - const [compactMode, setCompactMode] = useState(0); // 0: normal, 1: inline, 2: text only, 3: icon only - const [heightCompact, setHeightCompact] = useState(false); - const overlayRef = useRef(null); - - // Store the previous dimensions to avoid unnecessary updates - const prevDimensions = useRef<{ width: number; height: number }>({ width: 0, height: 0 }); - - useEffect(() => { - if (!containerRef.current || !overlayRef.current) return; - - // Debounced function to update compact modes - const updateCompactModes = debounce((width: number, height: number) => { - // Only update if dimensions have changed significantly (at least 5px difference) - if ( - Math.abs(prevDimensions.current.width - width) < 5 && - Math.abs(prevDimensions.current.height - height) < 5 - ) { - return; - } - - // Update previous dimensions - prevDimensions.current = { width, height }; - - // Determine compact mode based on width - let newCompactMode; - if (width < 150) { - newCompactMode = 3; // Ultra compact: only show lock icon - } else if (width < 250) { - newCompactMode = 2; // Compact: only show text - } else if (width < 400) { - newCompactMode = 1; // Slightly compact: show lock icon and text inline - } else { - newCompactMode = 0; // Normal: show lock icon above text - } - - // Use functional updates to avoid dependency on current state - setCompactMode(prevMode => { - return newCompactMode !== prevMode ? newCompactMode : prevMode; - }); - - // Determine height compact mode - const newHeightCompact = height < 100; - setHeightCompact(prevHeightCompact => { - return newHeightCompact !== prevHeightCompact ? newHeightCompact : prevHeightCompact; - }); - }, 50); // 50ms debounce - - const resizeObserver = new ResizeObserver(entries => { - for (const entry of entries) { - const width = entry.contentRect.width; - const height = entry.contentRect.height; - - // Use the debounced update function - updateCompactModes(width, height); - } - }); - - // Initial size check - if (containerRef.current) { - const { width, height } = containerRef.current.getBoundingClientRect(); - updateCompactModes(width, height); - } - - resizeObserver.observe(containerRef.current); - - return () => { - resizeObserver.disconnect(); - updateCompactModes.cancel(); - }; - }, [containerRef]); - - // Render the lock icon - const renderLockIcon = () => { - // Don't show icon in text-only mode or when height is very compact - if (compactMode === 2 || (heightCompact && compactMode !== 3)) { - return null; - } - - const iconSize = heightCompact ? 28 : compactMode === 3 ? 48 : compactMode === 1 ? 32 : 40; - - return ( -
- - - - -
- ); - }; - - // Render the title (Caps Lock ON) - const renderTitle = () => { - // Don't show title in icon-only mode - if (compactMode === 3) { - return null; - } - - const fontSize = heightCompact ? 11 : (compactMode === 2 ? 14 : 16); - - return ( -
- Caps Lock ON -
- ); - }; - - // Render the subtext (You can move and edit...) - const renderSubtext = () => { - // Don't show subtext in compact modes or when height is limited - if (compactMode >= 2 || heightCompact) { - return null; - } - - const fontSize = compactMode === 1 ? 12 : 13; - - return ( -
- You can move and edit this element -
- ); - }; - - // Determine the layout classes - const getLayoutClasses = () => { - const classes = []; - - // Add mode-specific class - if (compactMode === 1) classes.push('inline-layout'); - else if (compactMode === 2) classes.push('text-only-mode'); - else if (compactMode === 3) classes.push('icon-only-mode'); - - // Add height compact class if needed - if (heightCompact) classes.push('height-compact'); - - return classes.join(' '); - }; - - return ( -
-
- {renderLockIcon()} - {renderTitle()} - {renderSubtext()} -
-
- ); -}; - -export default CapsLockOverlay; diff --git a/src/frontend/src/ErrorBoundary.tsx b/src/frontend/src/ErrorBoundary.tsx deleted file mode 100644 index 6ddbe1e..0000000 --- a/src/frontend/src/ErrorBoundary.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { Component, ErrorInfo, ReactNode } from 'react'; - -interface Props { - children: ReactNode; - fallback?: ReactNode; -} - -interface State { - hasError: boolean; - error?: Error; -} - -class ErrorBoundary extends Component { - public state: State = { - hasError: false - }; - - public static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; - } - - public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // eslint-disable-next-line no-console - console.error('Uncaught error:', error, errorInfo); - } - - public render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback; - } - - return ( -
-

Something went wrong

-

{this.state.error?.message || 'An unknown error occurred'}

- -
- ); - } - - return this.props.children; - } -} - -export default ErrorBoundary; diff --git a/src/frontend/src/MainMenu.tsx b/src/frontend/src/MainMenu.tsx deleted file mode 100644 index 21ffe7b..0000000 --- a/src/frontend/src/MainMenu.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import React from 'react'; - -import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'; -import type { MainMenu as MainMenuType } from '@excalidraw/excalidraw'; - -import { LogOut, SquarePlus, LayoutDashboard, SquareCode, Eye, Coffee, Grid2x2, User } from 'lucide-react'; -import { capture } from './utils/posthog'; -import { ExcalidrawElementFactory, PlacementMode } from './lib/ExcalidrawElementFactory'; -import { useUserProfile } from "./api/hooks"; - -interface MainMenuConfigProps { - MainMenu: typeof MainMenuType; - excalidrawAPI: ExcalidrawImperativeAPI | null; -} - -export const MainMenuConfig: React.FC = ({ - MainMenu, - excalidrawAPI, -}) => { - const { data, isLoading, isError } = useUserProfile(); - - let username = ""; - if (isLoading) { - username = "Loading..."; - } else if (isError || !data?.username) { - username = "Unknown"; - } else { - username = data.username; - } - const handleHtmlEditorClick = () => { - if (!excalidrawAPI) return; - - const htmlEditorElement = ExcalidrawElementFactory.createEmbeddableElement({ - link: "!editor", - width: 1200, - height: 500 - }); - - ExcalidrawElementFactory.placeInScene(htmlEditorElement, excalidrawAPI, { - mode: PlacementMode.NEAR_VIEWPORT_CENTER, - bufferPercentage: 10, - scrollToView: true - }); - }; - - const handleDashboardButtonClick = () => { - if (!excalidrawAPI) return; - - const dashboardElement = ExcalidrawElementFactory.createEmbeddableElement({ - link: "!dashboard", - width: 460, - height: 80 - }); - - ExcalidrawElementFactory.placeInScene(dashboardElement, excalidrawAPI, { - mode: PlacementMode.NEAR_VIEWPORT_CENTER, - bufferPercentage: 10, - scrollToView: true - }); - }; - - const handleInsertButtonClick = () => { - if (!excalidrawAPI) return; - - const buttonElement = ExcalidrawElementFactory.createEmbeddableElement({ - link: "!button", - width: 460, - height: 80 - }); - - ExcalidrawElementFactory.placeInScene(buttonElement, excalidrawAPI, { - mode: PlacementMode.NEAR_VIEWPORT_CENTER, - bufferPercentage: 10, - scrollToView: true - }); - }; - - const handleGridToggle = () => { - if (!excalidrawAPI) return; - const appState = excalidrawAPI.getAppState(); - appState.gridModeEnabled = !appState.gridModeEnabled; - appState.gridSize = 20; - appState.gridStep = 5; - excalidrawAPI.updateScene({ - appState: appState - }); - }; - - const handleZenModeToggle = () => { - if (!excalidrawAPI) return; - const appState = excalidrawAPI.getAppState(); - appState.zenModeEnabled = !appState.zenModeEnabled; - excalidrawAPI.updateScene({ - appState: appState - }); - }; - - const handleViewModeToggle = () => { - if (!excalidrawAPI) return; - const appState = excalidrawAPI.getAppState(); - appState.viewModeEnabled = !appState.viewModeEnabled; - excalidrawAPI.updateScene({ - appState: appState - }); - }; - - return ( - -
- - - {username} - -
- - - - - - - - - - - - } - onClick={handleGridToggle} - > - Toggle Grid - - } - onClick={handleViewModeToggle} - > - View Mode - - } - onClick={handleZenModeToggle} - > - Zen Mode - - - - - - - - } - onClick={handleHtmlEditorClick} - > - Insert HTML Editor - - } - onClick={handleDashboardButtonClick} - > - Insert Dashboard - - } - onClick={handleInsertButtonClick} - > - Insert Button - - - - - - } - onClick={() => { - capture('logout_clicked'); - window.location.href = "/auth/logout"; - }} - > - Logout - - -
- ); -}; diff --git a/src/frontend/src/styles/DiscordButton.scss b/src/frontend/src/styles/DiscordButton.scss index 107dfc5..aa5e57a 100644 --- a/src/frontend/src/styles/DiscordButton.scss +++ b/src/frontend/src/styles/DiscordButton.scss @@ -16,3 +16,9 @@ outline: none; } } + +@media (max-width: 730px) { + .discord-button { + display: none !important; + } +} diff --git a/src/frontend/src/styles/FeedbackButton.scss b/src/frontend/src/styles/FeedbackButton.scss index 093b9a8..a1fde73 100644 --- a/src/frontend/src/styles/FeedbackButton.scss +++ b/src/frontend/src/styles/FeedbackButton.scss @@ -17,3 +17,9 @@ outline: none; } } + +@media (max-width: 730px) { + .feedback-button { + display: none !important; + } +} diff --git a/src/frontend/src/components/DiscordButton.tsx b/src/frontend/src/ui/DiscordButton.tsx similarity index 100% rename from src/frontend/src/components/DiscordButton.tsx rename to src/frontend/src/ui/DiscordButton.tsx diff --git a/src/frontend/src/components/FeedbackButton.tsx b/src/frontend/src/ui/FeedbackButton.tsx similarity index 100% rename from src/frontend/src/components/FeedbackButton.tsx rename to src/frontend/src/ui/FeedbackButton.tsx From 0026abc28aa3541d7f2189b141921c204d34e7f4 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 21 Apr 2025 11:47:11 +0000 Subject: [PATCH 3/8] feat: implement PostHog analytics for canvas events and user identification --- src/backend/main.py | 17 +++++++++++++-- src/backend/requirements.txt | 4 +++- src/backend/routers/canvas.py | 40 +++++++++++++++++++++++++++++++++-- src/backend/routers/user.py | 16 +++++++++++++- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/backend/main.py b/src/backend/main.py index 06f56bf..f2646dc 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -6,7 +6,18 @@ from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from api_analytics.fastapi import Analytics +from dotenv import load_dotenv +import posthog + +load_dotenv() + +POSTHOG_API_KEY = os.environ.get("VITE_PUBLIC_POSTHOG_KEY") +POSTHOG_HOST = os.environ.get("VITE_PUBLIC_POSTHOG_HOST") + +if POSTHOG_API_KEY: + posthog.project_api_key = POSTHOG_API_KEY + posthog.host = POSTHOG_HOST from db import init_db from config import STATIC_DIR, ASSETS_DIR @@ -24,6 +35,9 @@ async def lifespan(_: FastAPI): app = FastAPI(lifespan=lifespan) +# Add analytics middleware +app.add_middleware(Analytics, api_key="ea6d92e3-51d7-48f0-a327-8a38869ade13") + # CORS middleware setup app.add_middleware( CORSMiddleware, @@ -33,7 +47,6 @@ async def lifespan(_: FastAPI): allow_headers=["*"], ) -print("ASSETS_DIR", ASSETS_DIR) app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets") @app.get("/") diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 9cec4fc..6f2ae91 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -6,4 +6,6 @@ asyncpg python-dotenv PyJWT requests -sqlalchemy \ No newline at end of file +api-analytics[fastapi] +sqlalchemy +posthog diff --git a/src/backend/routers/canvas.py b/src/backend/routers/canvas.py index 18b93a1..5beaeef 100644 --- a/src/backend/routers/canvas.py +++ b/src/backend/routers/canvas.py @@ -1,11 +1,12 @@ import json import jwt from typing import Dict, Any -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request from fastapi.responses import JSONResponse from dependencies import SessionData, require_auth from db import store_canvas_data, get_canvas_data +import posthog canvas_router = APIRouter() @@ -19,14 +20,49 @@ def get_default_canvas_data(): detail=f"Failed to load default canvas: {str(e)}" ) +@canvas_router.get("/default") +async def get_default_canvas(auth: SessionData = Depends(require_auth)): + try: + with open("default_canvas.json", "r") as f: + canvas_data = json.load(f) + return canvas_data + except Exception as e: + return JSONResponse( + status_code=500, + content={"error": f"Failed to load default canvas: {str(e)}"} + ) + @canvas_router.post("") -async def save_canvas(data: Dict[str, Any], auth: SessionData = Depends(require_auth)): +async def save_canvas(data: Dict[str, Any], auth: SessionData = Depends(require_auth), request: Request = None): access_token = auth.token_data.get("access_token") decoded = jwt.decode(access_token, options={"verify_signature": False}) user_id = decoded["sub"] success = await store_canvas_data(user_id, data) if not success: raise HTTPException(status_code=500, detail="Failed to save canvas data") + # PostHog analytics: capture canvas_saved event + try: + app_state = data.get("appState", {}) + width = app_state.get("width") + height = app_state.get("height") + zoom = app_state.get("zoom", {}).get("value") + api_path = str(request.url.path) if request else None + full_url = None + if request: + full_url = str(request.base_url).rstrip("/") + str(request.url.path) + posthog.capture( + distinct_id=user_id, + event="canvas_saved", + properties={ + "pad_width": width, + "pad_height": height, + "pad_zoom": zoom, + "$current_url": full_url, + } + ) + except Exception as e: + print(f"Error capturing canvas_saved event: {str(e)}") + pass return {"status": "success"} @canvas_router.get("") diff --git a/src/backend/routers/user.py b/src/backend/routers/user.py index e4e89fb..ebe6223 100644 --- a/src/backend/routers/user.py +++ b/src/backend/routers/user.py @@ -1,5 +1,6 @@ import jwt from fastapi import APIRouter, Depends +import posthog from dependencies import SessionData, require_auth @@ -11,6 +12,19 @@ async def get_user_info(auth: SessionData = Depends(require_auth)): access_token = token_data.get("access_token") decoded = jwt.decode(access_token, options={"verify_signature": False}) + + # Identify user in PostHog (mirrors frontend identify) + posthog.identify( + distinct_id=decoded["sub"], + properties={ + "email": decoded.get("email", ""), + "username": decoded.get("preferred_username", ""), + "name": decoded.get("name", ""), + "given_name": decoded.get("given_name", ""), + "family_name": decoded.get("family_name", ""), + "email_verified": decoded.get("email_verified", False) + } + ) return { "id": decoded["sub"], # Unique user ID @@ -20,4 +34,4 @@ async def get_user_info(auth: SessionData = Depends(require_auth)): "given_name": decoded.get("given_name", ""), "family_name": decoded.get("family_name", ""), "email_verified": decoded.get("email_verified", False) - } + } \ No newline at end of file From e891ffc62d94e19e50fba0c0317927df0e98c356 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Mon, 21 Apr 2025 11:57:54 +0000 Subject: [PATCH 4/8] refactor: enhance authentication flow and UI components, update analytics --- src/frontend/src/App.tsx | 39 ++-- src/frontend/src/AuthGate.tsx | 54 +++-- src/frontend/src/CustomEmbeddableRenderer.tsx | 4 +- src/frontend/src/ExcalidrawWrapper.tsx | 8 +- src/frontend/src/auth/AuthModal.tsx | 5 +- src/frontend/src/pad/buttons/ActionButton.tsx | 20 +- src/frontend/src/ui/MainMenu.tsx | 184 ++++++++++++++++++ src/frontend/src/utils/posthog.ts | 16 +- 8 files changed, 257 insertions(+), 73 deletions(-) create mode 100644 src/frontend/src/ui/MainMenu.tsx diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0b03e0a..6e69c8e 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { useSaveCanvas } from "./api/hooks"; import type * as TExcalidraw from "@excalidraw/excalidraw"; import type { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import type { ExcalidrawImperativeAPI, AppState } from "@excalidraw/excalidraw/types"; -import AuthModal from "./auth/AuthModal"; import { useAuthCheck } from "./api/hooks"; export interface AppProps { @@ -26,10 +25,7 @@ export default function App({ }: AppProps) { const { useHandleLibrary, MainMenu } = excalidrawLib; - // Get authentication state from React Query const { data: isAuthenticated, isLoading: isAuthLoading } = useAuthCheck(); - - // Get user profile for analytics identification const { data: userProfile } = useUserProfile(); // Only enable canvas queries if authenticated and not loading @@ -44,12 +40,10 @@ export default function App({ useCustom(excalidrawAPI, customArgs); useHandleLibrary({ excalidrawAPI }); - // On login and canvas data load, update the scene - // Helper to ensure collaborators is a Map function normalizeCanvasData(data: any) { if (!data) return data; const appState = { ...data.appState }; - // Remove width and height so they get recomputed when loading from DB + appState.width = undefined; if ("width" in appState) { delete appState.width; } @@ -68,26 +62,18 @@ export default function App({ } }, [excalidrawAPI, canvasData]); - // Use React Query mutation for saving canvas const { mutate: saveCanvas } = useSaveCanvas({ onSuccess: () => { - console.debug("Canvas saved to database successfully"); - // Track canvas save event with PostHog - capture('canvas_saved'); + console.debug("[pad.ws] Canvas saved to database successfully"); }, onError: (error) => { - console.error("Failed to save canvas to database:", error); - // Track canvas save failure - capture('canvas_save_failed', { - error: error instanceof Error ? error.message : 'Unknown error' - }); + console.error("[pad.ws] Failed to save canvas to database:", error); } }); useEffect(() => { if (excalidrawAPI) { (window as any).excalidrawAPI = excalidrawAPI; - // Track application loaded event capture('app_loaded'); } return () => { @@ -95,13 +81,11 @@ export default function App({ }; }, [excalidrawAPI]); - // Ref to store the last sent canvas data for change detection const lastSentCanvasDataRef = useRef(""); const debouncedLogChange = useCallback( debounce( (elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => { - // Only save if authenticated if (!isAuthenticated) return; const canvasData = { @@ -110,11 +94,9 @@ export default function App({ files }; - // Compare with last sent data (deep equality via JSON.stringify) const serialized = JSON.stringify(canvasData); if (serialized !== lastSentCanvasDataRef.current) { lastSentCanvasDataRef.current = serialized; - // Use React Query mutation to save canvas saveCanvas(canvasData); } }, @@ -123,12 +105,18 @@ export default function App({ [saveCanvas, isAuthenticated] ); - // Identify user in PostHog when username is available useEffect(() => { - if (userProfile?.username) { - posthog.identify(userProfile.username); + if (userProfile?.id) { + posthog.identify(userProfile.id); + if (posthog.people && typeof posthog.people.set === "function") { + const { + id, // do not include in properties + ...personProps + } = userProfile; + posthog.people.set(personProps); + } } - }, [userProfile?.username]); + }, [userProfile]); return ( <> @@ -141,7 +129,6 @@ export default function App({ {children} - {/* AuthModal is now handled by AuthGate */} ); } diff --git a/src/frontend/src/AuthGate.tsx b/src/frontend/src/AuthGate.tsx index 76c10ae..1c1dfc6 100644 --- a/src/frontend/src/AuthGate.tsx +++ b/src/frontend/src/AuthGate.tsx @@ -1,40 +1,60 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useAuthCheck } from "./api/hooks"; import AuthModal from "./auth/AuthModal"; /** - * AuthGate ensures the authentication check is the very first XHR request. - * It blocks rendering of children until the auth check completes. - * If unauthenticated, it shows the AuthModal. + * If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it. + * + * If authenticated, it silently primes the Coder OIDC session by loading + * the OIDC callback endpoint in a hidden iframe. This is a workaround: + * without this, users would see the Coder login screen when opening an embedded terminal. + * + * The iframe is removed as soon as it loads, or after a fallback timeout. */ export default function AuthGate({ children }: { children: React.ReactNode }) { const { data: isAuthenticated, isLoading } = useAuthCheck(); const [coderAuthDone, setCoderAuthDone] = useState(false); + const iframeRef = useRef(null); + const timeoutRef = useRef(null); useEffect(() => { - // When authenticated, also authenticate with Coder using an iframe + // Only run the Coder OIDC priming once per session, after auth is confirmed if (isAuthenticated === true && !coderAuthDone) { - // Create a hidden iframe to handle Coder authentication - const iframe = document.createElement('iframe'); - iframe.style.display = 'none'; - iframe.src = 'https://coder.pad.ws/api/v2/users/oidc/callback'; + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.src = "https://coder.pad.ws/api/v2/users/oidc/callback"; - // Add the iframe to the document + // Remove iframe as soon as it loads, or after 2s fallback + const cleanup = () => { + if (iframe.parentNode) iframe.parentNode.removeChild(iframe); + setCoderAuthDone(true); + }; + + iframe.onload = cleanup; document.body.appendChild(iframe); + iframeRef.current = iframe; - // Clean up after a short delay - setTimeout(() => { - document.body.removeChild(iframe); - setCoderAuthDone(true); - }, 2000); // 2 seconds should be enough for the auth to complete + // Fallback: remove iframe after 5s if onload doesn't fire + timeoutRef.current = window.setTimeout(cleanup, 5000); + + // Cleanup on unmount or re-run + return () => { + if (iframeRef.current && iframeRef.current.parentNode) { + iframeRef.current.parentNode.removeChild(iframeRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, coderAuthDone]); - // Always render children (App), but overlay AuthModal if unauthenticated + // Always render children; overlay AuthModal if not authenticated return ( <> {children} - {isAuthenticated === false && !isLoading && } + {isAuthenticated === false && } ); } diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index d3649f9..8cdeed7 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -38,7 +38,7 @@ export const renderCustomEmbeddable = ( default: return null; } + } else { + return