diff --git a/src/backend/main.py b/src/backend/main.py index 06f56bf..a9d2f1d 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -6,7 +6,17 @@ from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +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 @@ -33,7 +43,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..a222b75 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -6,4 +6,5 @@ asyncpg python-dotenv PyJWT requests -sqlalchemy \ No newline at end of file +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 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; - 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/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/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/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