├── .env.template ├── .github └── workflows │ └── docker-build.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs ├── canvas_ide.png ├── canvas_ide_old.png └── frontend-backend-communication.md └── src ├── backend ├── coder.py ├── config.py ├── db.py ├── default_canvas.json ├── dependencies.py ├── main.py ├── requirements.txt └── routers │ ├── __init__.py │ ├── auth.py │ ├── canvas.py │ ├── user.py │ └── workspace.py └── frontend ├── index.html ├── index.tsx ├── package.json ├── public ├── assets │ ├── fonts │ │ ├── Roboto-Italic-VariableFont_wdth,wght.ttf │ │ └── Roboto-VariableFont_wdth,wght.ttf │ └── images │ │ └── favicon.png └── auth │ └── popup-close.html ├── src ├── App.tsx ├── AuthGate.tsx ├── CustomEmbeddableRenderer.tsx ├── ExcalidrawWrapper.tsx ├── api │ ├── apiUtils.ts │ ├── hooks.ts │ └── queryClient.ts ├── auth │ └── AuthModal.tsx ├── env.d.ts ├── lib │ └── ExcalidrawElementFactory.ts ├── pad │ ├── buttons │ │ ├── ActionButton.tsx │ │ ├── ActionButtonGrid.tsx │ │ ├── index.ts │ │ └── types.ts │ ├── containers │ │ └── Dashboard.tsx │ ├── controls │ │ ├── ControlButton.tsx │ │ └── StateIndicator.tsx │ ├── editors │ │ ├── Editor.tsx │ │ ├── HtmlEditor.tsx │ │ ├── LanguageSelector.tsx │ │ └── index.ts │ ├── index.ts │ └── styles │ │ ├── ActionButton.scss │ │ ├── ActionButtonGrid.scss │ │ ├── ControlButton.scss │ │ ├── Dashboard.scss │ │ ├── StateIndicator.scss │ │ └── index.scss ├── styles │ ├── AuthModal.scss │ ├── CustomEmbeddableRenderer.scss │ ├── DiscordButton.scss │ ├── Editor.scss │ ├── FeedbackButton.scss │ ├── HtmlEditor.scss │ ├── MainMenuLabel.scss │ ├── fonts.scss │ └── index.scss ├── ui │ ├── DiscordButton.tsx │ ├── FeedbackButton.tsx │ └── MainMenu.tsx └── utils │ ├── debounce.ts │ ├── elementPlacement.ts │ └── posthog.ts ├── tsconfig.json ├── vite.config.mts └── yarn.lock /.env.template: -------------------------------------------------------------------------------- 1 | # Port Configuration 2 | POSTGRES_PORT=5432 3 | KEYCLOAK_PORT=8080 4 | CODER_PORT=7080 5 | APP_PORT=8000 6 | 7 | # Database Configuration 8 | POSTGRES_USER=admin 9 | POSTGRES_PASSWORD=admin123 10 | POSTGRES_DB=pad 11 | 12 | # Keycloak Configuration 13 | KEYCLOAK_ADMIN=admin 14 | KEYCLOAK_ADMIN_PASSWORD=admin123 15 | 16 | # Fill this after you have created a realm and client in keycloak 17 | OIDC_REALM=your_realm 18 | OIDC_CLIENT_ID=your_client_id 19 | OIDC_CLIENT_SECRET=your_client_secret 20 | 21 | # Docker group id for coder, get it with: getent group docker | cut -d: -f3 22 | DOCKER_GROUP_ID=your_docker_group_id 23 | 24 | # Coder Configuration 25 | CODER_ADDITIONAL_CSP_POLICY=frame-ancestors * 26 | CODER_API_KEY=your_coder_api_key 27 | CODER_TEMPLATE_ID=your_template_id 28 | CODER_DEFAULT_ORGANIZATION=your_organization_id 29 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | workflow_dispatch: 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build-and-push: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | tags: | 41 | type=ref,event=branch 42 | type=ref,event=pr 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | type=sha,format=long 46 | 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@v5 49 | with: 50 | context: . 51 | push: ${{ github.event_name != 'pull_request' }} 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | platforms: linux/amd64,linux/arm64 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max 57 | builder: ${{ steps.buildx.outputs.name }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | */__pycache__/* 3 | node_modules 4 | dist 5 | src/excalidraw 6 | .env 7 | src/frontend/.env.local 8 | .vscode/settings.json 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim as frontend-builder 2 | WORKDIR /app/frontend 3 | COPY src/frontend/package.json src/frontend/yarn.lock ./ 4 | RUN yarn install --frozen-lockfile 5 | COPY src/frontend/ ./ 6 | RUN yarn build 7 | 8 | FROM python:3.11-slim 9 | WORKDIR /app 10 | COPY src/backend /app 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist 13 | 14 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 pad.ws 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pad.ws - whiteboard as an IDE 🎨 2 | 3 | 4 | 5 | [![Pad.ws Canvas IDE](docs/canvas_ide.png)](https://pad.ws) 6 | 7 | [pad.ws](https://pad.ws) is a whiteboard app that acts as a dev environment in your browser 8 | 9 | ## ✨ Features 10 | 11 | * 🎨 **Interactive Whiteboard** - Draw, sketch and visualize your ideas with Excalidraw 12 | * 💻 **Fully fledged IDE** - Access terminals and VS Code directly within the whiteboard 13 | * ☁️ **Browser friendly** - Access your dev env from any device 14 | * 🔄 **Seamless Workflow** - Switch between visual ideation and coding 15 | * 🛠️ **Use your own tools** - Access your VM from your desktop client (VS Code & Cursor supported) 16 | 17 | This uses [Excalidraw](https://github.com/excalidraw/excalidraw) for the whiteboard interface while [Coder](https://github.com/coder/coder) powers the cloud development environments. 18 | 19 | 20 | ## Try it online 🌐 21 | 22 | Visit [pad.ws](https://pad.ws) for an official managed instance. During this beta, we offer free ubuntu dev environments without any setup 23 | 24 | ## Self-Hosting 🛠️ 25 | 26 | ⚠️ IMPORTANT NOTICE: This repository is in early development stage. The setup provided in `docker-compose.yml` is for development and testing purposes only. 27 | This simplified example lets you host pad on `localhost` but is not safe for real-life use without further configurations ⚠️ 28 | 29 | 30 | 31 | 32 | ### ✅ Prerequisites 33 | * **Linux Host** (This was tested on Ubuntu only) 34 | * **Docker & Docker Compose:** Ensure you have both installed. [Install Docker](https://docs.docker.com/get-docker/) / [Install Docker Compose](https://docs.docker.com/compose/install/) 35 | 36 | 37 | ### 1️⃣ .env 38 | 39 | * Copy and review the default values 40 | ```bash 41 | cp .env.template .env 42 | ``` 43 | 44 | ### 2️⃣ PostgreSQL 🐘 45 | > Ensure persistence for the whole deployment (canvases and configs) 46 | 47 | * Run the PostgreSQL container using the provided configuration (e.g., in your `docker-compose.yml`) 48 | 49 | ```bash 50 | docker compose up -d postgres 51 | ``` 52 | 53 | ### 3️⃣ Keycloak 🔑 54 | > OIDC provider for access and user management (within coder and pad app) 55 | * Run the Keycloak container 56 | ```bash 57 | docker compose up -d keycloak 58 | ``` 59 | * Access the Keycloak admin console http://localhost:8080 60 | * **Create a Realm:** Name it appropriately (e.g., `pad-ws`) 61 | * **Create a Client:** 62 | * Give it a `Client ID` (e.g., `pad-ws-client`) 63 | * Enable **Client Authentication** 64 | * Add * to the valid redirect urls 65 | * You can leave other settings as default for now 66 | * **Get Credentials:** 67 | * Navigate to `Clients` -> `[Your Client ID]` -> `Credentials` tab 68 | * Note the **Client secret**. 69 | * Update your environment variables file (`.env`) with: 70 | ```dotenv 71 | OIDC_REALM=your_oidc_realm 72 | OIDC_CLIENT_ID=your_client_id 73 | OIDC_CLIENT_SECRET=your_client_secret 74 | ``` 75 | * **Create a User:** 76 | * Navigate to `Users` -> `Create user` 77 | * Fill in the details 78 | * **Important:** Tick `Email verified` 79 | * Go to the `Credentials` tab for the new user and set a password 80 | 81 | ### 4️⃣ Coder 🧑‍💻 82 | 83 | * **Find Docker Group ID:** You'll need this to grant necessary permissions 84 | ```bash 85 | getent group docker | cut -d: -f3 86 | ``` 87 | * Update your `.env` file with the `DOCKER_GROUP_ID`: 88 | ```dotenv 89 | DOCKER_GROUP_ID=your_docker_group_id 90 | ``` 91 | * Run the Coder container. 92 | ```bash 93 | docker compose up -d coder 94 | ``` 95 | * **Access Coder UI:** Open [localhost:7080](http://localhost:7080) in your browser 96 | * **First Login:** Create an administrator user (e.g., `admin`) 97 | * **Create a Template:** 98 | * Use the "Start from template" option. 99 | * Choose a base image (e.g., `docker-containers` or a simple Ubuntu). Configure it as needed 100 | * **Generate API Key:** 101 | * Click your profile picture (top right) -> `Account` -> `API Keys` 102 | * Generate a new token 103 | * Update your `.env` 104 | ```dotenv 105 | CODER_API_KEY=your_coder_api_key 106 | ``` 107 | * **Get Template ID:** 108 | * Visit `http://localhost:7080/api/v2/templates` in your browser (or use `curl`) 109 | * Find the `id` of the template you created 110 | * Update your `.env` 111 | ```dotenv 112 | CODER_TEMPLATE_ID=your_coder_template_id # Example: 85fb21ba-085b-47a6-9f4d-94ea979aaba9 113 | ``` 114 | * **Get Default Organization ID:** 115 | * Visit `http://localhost:7080/api/v2/organizations` in your browser (or use `curl`) 116 | * Find the `id` of your organization (usually the default one) 117 | * Update your `.env`: 118 | ```dotenv 119 | CODER_DEFAULT_ORGANIZATION=your_organization_id # Example: 70f6af06-ef3a-4b4c-a663-c03c9ee423bb 120 | ``` 121 | 122 | ### 5️⃣ Pad App 📝 123 | > The fastAPI app that both serves the build frontend and the backend API to interface with Coder 124 | 125 | * **Run the Application:** 126 | * Ensure all environment variables in your `.env` file are correctly set 127 | * Run the `pad` application container 128 | 129 | ```bash 130 | docker compose up -d pad 131 | ``` 132 | 133 | 🎉 **Congratulations!** You should now be able to access and login to your self-hosted pad at [localhost:8000](http://localhost:8000) 134 | 135 | 🚧 *Did you have any issue while following this guide?* 136 | *Please [let us know](https://github.com/pad-ws/pad.ws/issues) so we can improve the onboarding flow* 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:16 6 | container_name: postgres 7 | environment: 8 | POSTGRES_USER: ${POSTGRES_USER} 9 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 10 | POSTGRES_DB: ${POSTGRES_DB} 11 | volumes: 12 | - postgres_data:/var/lib/postgresql/data 13 | restart: unless-stopped 14 | network_mode: host 15 | 16 | keycloak: 17 | image: quay.io/keycloak/keycloak:25.0 18 | container_name: keycloak 19 | command: start 20 | environment: 21 | KC_HOSTNAME: localhost 22 | KC_HOSTNAME_PORT: ${KEYCLOAK_PORT} 23 | KC_HTTP_ENABLED: "true" 24 | KC_HOSTNAME_STRICT_BACKCHANNEL: "false" 25 | KC_HOSTNAME_STRICT_HTTPS: "false" 26 | KC_HOSTNAME_URL: http://localhost:${KEYCLOAK_PORT} 27 | KC_HOSTNAME_ADMIN_URL: http://localhost:${KEYCLOAK_PORT} 28 | KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} 29 | KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} 30 | KC_PROXY: "edge" 31 | PROXY_ADDRESS_FORWARDING: "true" 32 | KC_DB: postgres 33 | KC_DB_URL: jdbc:postgresql://localhost:5432/${POSTGRES_DB} 34 | KC_DB_USERNAME: ${POSTGRES_USER} 35 | KC_DB_PASSWORD: ${POSTGRES_PASSWORD} 36 | restart: unless-stopped 37 | network_mode: host 38 | 39 | coder: 40 | image: ghcr.io/coder/coder:latest 41 | container_name: coder 42 | environment: 43 | CODER_ACCESS_URL: http://localhost:${CODER_PORT} 44 | CODER_OIDC_ISSUER_URL: http://localhost:8080/realms/${OIDC_REALM} 45 | CODER_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID} 46 | CODER_OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} 47 | CODER_OIDC_SIGN_IN_TEXT: "Sign in for pad" 48 | CODER_ADDITIONAL_CSP_POLICY: ${CODER_ADDITIONAL_CSP_POLICY} 49 | CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLED: "false" 50 | CODER_PG_CONNECTION_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=disable 51 | CODER_ADDRESS: 0.0.0.0:7080 52 | CODER_OIDC_IGNORE_EMAIL_VERIFIED: "true" 53 | volumes: 54 | - /var/run/docker.sock:/var/run/docker.sock 55 | group_add: 56 | - ${DOCKER_GROUP_ID} 57 | restart: unless-stopped 58 | network_mode: host 59 | 60 | pad: 61 | image: ghcr.io/pad-ws/pad.ws:main 62 | container_name: pad 63 | environment: 64 | - STATIC_DIR=/app/frontend/dist 65 | - ASSETS_DIR=/app/frontend/dist/assets 66 | - OIDC_CLIENT_ID=${OIDC_CLIENT_ID} 67 | - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} 68 | - OIDC_SERVER_URL=http://localhost:${KEYCLOAK_PORT} 69 | - OIDC_REALM=${OIDC_REALM} 70 | - REDIRECT_URI=http://localhost:${APP_PORT}/auth/callback 71 | - POSTGRES_USER=${POSTGRES_USER} 72 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 73 | - POSTGRES_DB=${POSTGRES_DB} 74 | - POSTGRES_HOST=localhost 75 | - POSTGRES_PORT=${POSTGRES_PORT} 76 | - CODER_API_KEY=${CODER_API_KEY} 77 | - CODER_URL=http://localhost:${CODER_PORT} 78 | - CODER_TEMPLATE_ID=${CODER_TEMPLATE_ID} 79 | - CODER_DEFAULT_ORGANIZATION=${CODER_DEFAULT_ORGANIZATION} 80 | network_mode: host 81 | 82 | volumes: 83 | postgres_data: 84 | -------------------------------------------------------------------------------- /docs/canvas_ide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pad-ws/pad.ws/8bb75f9035e28bcb9c0e67f94e83c3fb20da6b04/docs/canvas_ide.png -------------------------------------------------------------------------------- /docs/canvas_ide_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pad-ws/pad.ws/8bb75f9035e28bcb9c0e67f94e83c3fb20da6b04/docs/canvas_ide_old.png -------------------------------------------------------------------------------- /docs/frontend-backend-communication.md: -------------------------------------------------------------------------------- 1 | # Frontend-Backend Communication (React Query Architecture) 2 | 3 | This document describes the current architecture and all communication points between the frontend and backend in the Pad.ws application, following the React Query refactor. All API interactions are now managed through React Query hooks, providing deduplication, caching, polling, and robust error handling. 4 | 5 | --- 6 | 7 | ## 1. Overview of Communication Architecture 8 | 9 | - **All frontend-backend communication is handled via React Query hooks.** 10 | - **API calls are centralized in `src/frontend/src/api/hooks.ts` and `apiUtils.ts`.** 11 | - **No custom context providers for authentication or workspace state are used; hooks are called directly in components.** 12 | - **Error and loading states are managed by React Query.** 13 | - **Mutations (e.g., saving data, starting/stopping workspace) automatically invalidate relevant queries.** 14 | 15 | --- 16 | 17 | ## 2. Authentication 18 | 19 | ### 2.1. Authentication Status 20 | 21 | - **Hook:** `useAuthCheck` 22 | - **Endpoint:** `GET /api/workspace/state` 23 | - **Usage:** Determines if the user is authenticated. Returns `true` if authenticated, `false` if 401 Unauthorized. 24 | - **Example:** 25 | ```typescript 26 | import { useAuthCheck } from "./api/hooks"; 27 | const { data: isAuthenticated = true } = useAuthCheck(); 28 | ``` 29 | - **UI:** If `isAuthenticated` is `false`, the login modal (`AuthModal`) is displayed. 30 | 31 | ### 2.2. Login/Logout 32 | 33 | - **Login:** Handled via OAuth redirects (e.g., `/auth/login?kc_idp_hint=google`). 34 | - **Logout:** Handled via redirect to `/auth/logout`. 35 | - **No direct API call from React Query; handled by browser navigation.** 36 | 37 | --- 38 | 39 | ## 3. User Profile 40 | 41 | - **Hook:** `useUserProfile` 42 | - **Endpoint:** `GET /api/user/me` 43 | - **Usage:** Fetches the authenticated user's profile. 44 | - **Example:** 45 | ```typescript 46 | import { useUserProfile } from "./api/hooks"; 47 | const { data: userProfile, isLoading, error } = useUserProfile(); 48 | ``` 49 | 50 | --- 51 | 52 | ## 4. Workspace Management 53 | 54 | ### 4.1. Workspace State 55 | 56 | - **Hook:** `useWorkspaceState` 57 | - **Endpoint:** `GET /api/workspace/state` 58 | - **Usage:** Polls workspace state every 5 seconds. 59 | - **Example:** 60 | ```typescript 61 | import { useWorkspaceState } from "./api/hooks"; 62 | const { data: workspaceState, isLoading, error } = useWorkspaceState(); 63 | ``` 64 | 65 | ### 4.2. Start/Stop Workspace 66 | 67 | - **Hooks:** `useStartWorkspace`, `useStopWorkspace` 68 | - **Endpoints:** `POST /api/workspace/start`, `POST /api/workspace/stop` 69 | - **Usage:** Mutations to start/stop the workspace. On success, invalidate and refetch workspace state. 70 | - **Example:** 71 | ```typescript 72 | import { useStartWorkspace, useStopWorkspace } from "./api/hooks"; 73 | const { mutate: startWorkspace } = useStartWorkspace(); 74 | const { mutate: stopWorkspace } = useStopWorkspace(); 75 | // Usage: startWorkspace(); stopWorkspace(); 76 | ``` 77 | 78 | --- 79 | 80 | ## 5. Canvas Data Management 81 | 82 | ### 5.1. Load Canvas 83 | 84 | - **Hooks:** `useCanvas`, `useDefaultCanvas` 85 | - **Endpoints:** `GET /api/canvas`, `GET /api/canvas/default` 86 | - **Usage:** Loads user canvas data; falls back to default if not available or on error. 87 | - **Example:** 88 | ```typescript 89 | import { useCanvas, useDefaultCanvas } from "./api/hooks"; 90 | const { data: canvasData, isError } = useCanvas(); 91 | const { data: defaultCanvasData } = useDefaultCanvas({ enabled: isError }); 92 | ``` 93 | 94 | ### 5.2. Save Canvas 95 | 96 | - **Hook:** `useSaveCanvas` 97 | - **Endpoint:** `POST /api/canvas` 98 | - **Usage:** Saves canvas data. Only called if user is authenticated. 99 | - **Example:** 100 | ```typescript 101 | import { useSaveCanvas } from "./api/hooks"; 102 | const { mutate: saveCanvas } = useSaveCanvas(); 103 | // Usage: saveCanvas(canvasData); 104 | ``` 105 | 106 | --- 107 | 108 | ## 6. Error Handling 109 | 110 | - **All API errors are handled by React Query and the `fetchApi` utility.** 111 | - **401 Unauthorized:** Triggers unauthenticated state; login modal is shown. 112 | - **Other errors:** Exposed via `error` property in hook results; components can display error messages or fallback UI. 113 | - **Example:** 114 | ```typescript 115 | const { data, error, isLoading } = useWorkspaceState(); 116 | if (error) { /* Show error UI */ } 117 | ``` 118 | 119 | --- 120 | 121 | ## 7. API Utility Functions 122 | 123 | - **File:** `src/frontend/src/api/apiUtils.ts` 124 | - **Functions:** `fetchApi`, `handleResponse` 125 | - **Purpose:** Centralizes fetch logic, error handling, and credentials management for all API calls. 126 | 127 | --- 128 | 129 | ## 8. Summary 130 | 131 | - **All frontend-backend communication is now declarative and managed by React Query hooks.** 132 | - **No legacy context classes or direct fetches remain.** 133 | - **API logic is centralized, maintainable, and testable.** 134 | - **Error handling, caching, and polling are handled automatically.** 135 | - **UI components react to hook state for loading, error, and data.** 136 | 137 | This architecture ensures robust, efficient, and maintainable communication between the frontend and backend. 138 | -------------------------------------------------------------------------------- /src/backend/coder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from dotenv import load_dotenv 4 | 5 | class CoderAPI: 6 | """ 7 | A class for interacting with the Coder API using credentials from .env file 8 | """ 9 | 10 | def __init__(self): 11 | # Load environment variables from .env file 12 | load_dotenv() 13 | 14 | # Get configuration from environment variables 15 | self.api_key = os.getenv("CODER_API_KEY") 16 | self.coder_url = os.getenv("CODER_URL") 17 | self.user_id = os.getenv("USER_ID") 18 | self.template_id = os.getenv("CODER_TEMPLATE_ID") 19 | self.default_organization_id = os.getenv("CODER_DEFAULT_ORGANIZATION") 20 | 21 | # Check if required environment variables are set 22 | if not self.api_key or not self.coder_url: 23 | raise ValueError("CODER_API_KEY and CODER_URL must be set in .env file") 24 | 25 | # Set up common headers for API requests 26 | self.headers = { 27 | 'Accept': 'application/json', 28 | 'Coder-Session-Token': self.api_key 29 | } 30 | 31 | def get_users(self): 32 | """ 33 | Get all users from the Coder API 34 | """ 35 | endpoint = f"{self.coder_url}/api/v2/users" 36 | response = requests.get(endpoint, headers=self.headers) 37 | response.raise_for_status() # Raise exception for 4XX/5XX responses 38 | return response.json()['users'] 39 | 40 | 41 | def create_workspace(self, user_id, parameter_values=None): 42 | """ 43 | Create a new workspace for a user using a template 44 | 45 | Args: 46 | user_id (str, optional): User ID to create the workspace for. Defaults to USER_ID from .env. 47 | name (str, optional): Name for the new workspace. Defaults to a generated name. 48 | template_id (str, optional): Template ID to use. Defaults to TEMPLATE_ID from .env. 49 | parameter_values (list, optional): List of template parameter values. Example: 50 | [{"name": "param_name", "value": "param_value"}] 51 | 52 | Returns: 53 | dict: Created workspace data 54 | """ 55 | 56 | template_id = self.template_id 57 | 58 | if not template_id: 59 | raise ValueError("template_id must be provided or TEMPLATE_ID must be set in .env") 60 | 61 | name = os.getenv("CODER_WORKSPACE_NAME", "ubuntu") 62 | 63 | # Prepare the request data 64 | data = { 65 | "name": name, 66 | "template_id": template_id 67 | } 68 | 69 | # Add rich_parameter_values if provided 70 | if parameter_values: 71 | data["rich_parameter_values"] = parameter_values 72 | 73 | # Set headers for JSON content 74 | headers = self.headers.copy() 75 | headers['Content-Type'] = 'application/json' 76 | 77 | # Create the workspace 78 | print("Creating workspace for user", user_id) 79 | endpoint = f"{self.coder_url}/api/v2/users/{user_id}/workspaces" 80 | response = requests.post(endpoint, headers=headers, json=data) 81 | 82 | response.raise_for_status() 83 | return response.json() 84 | 85 | 86 | 87 | 88 | def _get_all_templates(self): 89 | """ 90 | Get all templates from the Coder API 91 | """ 92 | endpoint = f"{self.coder_url}/api/v2/templates" 93 | response = requests.get(endpoint, headers=self.headers) 94 | response.raise_for_status() 95 | return response.json() 96 | 97 | def get_user_by_email(self, email): 98 | """ 99 | Get a user by email 100 | 101 | Args: 102 | email (str): email to search for 103 | 104 | Returns: 105 | dict: User data if found, None otherwise 106 | """ 107 | users = self.get_users() 108 | for user in users: 109 | if user['email'] == email: 110 | return user 111 | return None 112 | 113 | def create_user(self, username, email, name): 114 | """ 115 | Create a new user in Coder 116 | 117 | Args: 118 | username (str): Username for the new user 119 | email (str): Email for the new user 120 | name (str, optional): Full name for the new user 121 | login_type (str, optional): Login type, defaults to "oidc" 122 | organization_ids (list, optional): List of organization IDs to add the user to. 123 | If not provided, use default organization from .env 124 | 125 | Returns: 126 | dict: Created user data 127 | """ 128 | login_type="oidc" 129 | endpoint = f"{self.coder_url}/api/v2/users" 130 | organization_ids = [self.default_organization_id] 131 | 132 | data = { 133 | "username": username, 134 | "email": email, 135 | "login_type": login_type, 136 | "organization_ids": organization_ids 137 | } 138 | 139 | if name: 140 | data["name"] = name 141 | 142 | # Set headers for JSON content 143 | headers = self.headers.copy() 144 | headers['Content-Type'] = 'application/json' 145 | 146 | response = requests.post(endpoint, headers=headers, json=data) 147 | response.raise_for_status() 148 | return response.json() 149 | 150 | def ensure_user_exists(self, user_info): 151 | """ 152 | Ensure a user exists in Coder, creating them if they don't 153 | 154 | Args: 155 | user_info (dict): User information containing email and name 156 | 157 | Returns: 158 | dict: User data 159 | bool: Whether the user was newly created 160 | """ 161 | 162 | email = user_info.get('email', '') 163 | name = user_info.get('name', '') 164 | 165 | # First check if user exists by email 166 | existing_user = self.get_user_by_email(email) 167 | if existing_user: 168 | return existing_user, False 169 | 170 | # Generate base username from email (everything before @, lowercase, alphanumeric only) 171 | base_username = ''.join(c for c in email.split('@')[0].lower() if c.isalnum()) 172 | 173 | # If base username is empty, use a default 174 | if not base_username: 175 | base_username = 'user' 176 | 177 | # Ensure username is unique 178 | username = base_username 179 | for i in range(10): 180 | # Check if username exists 181 | users = self.get_users() 182 | username_exists = any(user['username'] == username for user in users) 183 | if not username_exists: 184 | break 185 | # If username exists, append a number 186 | username = f"{base_username}{i}" 187 | else: 188 | raise Exception("Failed to create unique username") 189 | 190 | new_user = self.create_user(username, email, name) 191 | return new_user, True 192 | 193 | 194 | def get_workspace_status_for_user(self, username): 195 | """ 196 | Get the status of a user's workspace 197 | 198 | Args: 199 | username (str): Username to get workspace status for 200 | 201 | Returns: 202 | dict: Workspace status data if found, None otherwise 203 | """ 204 | workspace_name = os.getenv("CODER_WORKSPACE_NAME", "ubuntu") 205 | 206 | endpoint = f"{self.coder_url}/api/v2/users/{username}/workspace/{workspace_name}" 207 | response = requests.get(endpoint, headers=self.headers) 208 | 209 | # If workspace not found, return None 210 | if response.status_code == 404: 211 | return None 212 | 213 | response.raise_for_status() 214 | return response.json() 215 | 216 | def ensure_workspace_exists(self, username): 217 | """ 218 | Ensure a workspace exists for a user 219 | """ 220 | workspace = self.get_workspace_status_for_user(username) 221 | if not workspace: 222 | self.create_workspace(username) 223 | 224 | def start_workspace(self, workspace_id): 225 | """ 226 | Start a workspace by creating a build with start transition 227 | 228 | Args: 229 | workspace_id (str): The ID of the workspace to start 230 | 231 | Returns: 232 | dict: Response from the API 233 | """ 234 | # First get the workspace to get its template version 235 | workspace_endpoint = f"{self.coder_url}/api/v2/workspaces/{workspace_id}" 236 | workspace_response = requests.get(workspace_endpoint, headers=self.headers) 237 | workspace_response.raise_for_status() 238 | workspace = workspace_response.json() 239 | 240 | endpoint = f"{self.coder_url}/api/v2/workspaces/{workspace_id}/builds" 241 | data = { 242 | "dry_run": False, 243 | "orphan": False, 244 | "rich_parameter_values": [], 245 | "state": [], 246 | "template_version_id": workspace["template_active_version_id"], 247 | "transition": "start" 248 | } 249 | headers = self.headers.copy() 250 | headers['Content-Type'] = 'application/json' 251 | response = requests.post(endpoint, headers=headers, json=data) 252 | response.raise_for_status() 253 | return response.json() 254 | 255 | def stop_workspace(self, workspace_id): 256 | """ 257 | Stop a workspace by creating a build with stop transition 258 | 259 | Args: 260 | workspace_id (str): The ID of the workspace to stop 261 | 262 | Returns: 263 | dict: Response from the API 264 | """ 265 | # First get the workspace to get its template version 266 | workspace_endpoint = f"{self.coder_url}/api/v2/workspaces/{workspace_id}" 267 | workspace_response = requests.get(workspace_endpoint, headers=self.headers) 268 | workspace_response.raise_for_status() 269 | workspace = workspace_response.json() 270 | 271 | 272 | endpoint = f"{self.coder_url}/api/v2/workspaces/{workspace_id}/builds" 273 | data = { 274 | "dry_run": False, 275 | "orphan": False, 276 | "rich_parameter_values": [], 277 | "state": [], 278 | "template_version_id": workspace["template_active_version_id"], 279 | "transition": "stop" 280 | } 281 | headers = self.headers.copy() 282 | headers['Content-Type'] = 'application/json' 283 | response = requests.post(endpoint, headers=headers, json=data) 284 | response.raise_for_status() 285 | return response.json() -------------------------------------------------------------------------------- /src/backend/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | STATIC_DIR = os.getenv("STATIC_DIR") 7 | ASSETS_DIR = os.getenv("ASSETS_DIR") 8 | 9 | OIDC_CONFIG = { 10 | 'client_id': os.getenv('OIDC_CLIENT_ID'), 11 | 'client_secret': os.getenv('OIDC_CLIENT_SECRET'), 12 | 'server_url': os.getenv('OIDC_SERVER_URL'), 13 | 'realm': os.getenv('OIDC_REALM'), 14 | 'redirect_uri': os.getenv('REDIRECT_URI') 15 | } 16 | 17 | sessions = {} 18 | provisioning_times = {} 19 | 20 | def get_auth_url() -> str: 21 | """Generate the authentication URL for Keycloak login""" 22 | auth_url = f"{OIDC_CONFIG['server_url']}/realms/{OIDC_CONFIG['realm']}/protocol/openid-connect/auth" 23 | params = { 24 | 'client_id': OIDC_CONFIG['client_id'], 25 | 'response_type': 'code', 26 | 'redirect_uri': OIDC_CONFIG['redirect_uri'] 27 | } 28 | return f"{auth_url}?{'&'.join(f'{k}={v}' for k,v in params.items())}" 29 | 30 | def get_token_url() -> str: 31 | """Get the token endpoint URL""" 32 | return f"{OIDC_CONFIG['server_url']}/realms/{OIDC_CONFIG['realm']}/protocol/openid-connect/token" 33 | -------------------------------------------------------------------------------- /src/backend/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Dict, Any 3 | from dotenv import load_dotenv 4 | from sqlalchemy import Column, String, JSON, DateTime, func, create_engine 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 7 | from sqlalchemy.orm import sessionmaker 8 | from sqlalchemy.future import select 9 | 10 | load_dotenv() 11 | 12 | # PostgreSQL connection configuration 13 | DB_USER = os.getenv('POSTGRES_USER', 'postgres') 14 | DB_PASSWORD = os.getenv('POSTGRES_PASSWORD', 'postgres') 15 | DB_NAME = os.getenv('POSTGRES_DB', 'pad') 16 | DB_HOST = os.getenv('POSTGRES_HOST', 'localhost') 17 | DB_PORT = os.getenv('POSTGRES_PORT', '5432') 18 | 19 | # SQLAlchemy async database URL 20 | DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" 21 | 22 | # Create async engine 23 | engine = create_async_engine(DATABASE_URL, echo=False) 24 | 25 | # Create async session factory 26 | async_session = sessionmaker( 27 | engine, class_=AsyncSession, expire_on_commit=False 28 | ) 29 | 30 | # Create base model 31 | Base = declarative_base() 32 | 33 | class CanvasData(Base): 34 | """Model for canvas data table""" 35 | __tablename__ = "canvas_data" 36 | 37 | user_id = Column(String, primary_key=True) 38 | data = Column(JSON, nullable=False) 39 | updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) 40 | 41 | def __repr__(self): 42 | return f"" 43 | 44 | async def get_db_session(): 45 | """Get a database session""" 46 | async with async_session() as session: 47 | yield session 48 | 49 | async def init_db(): 50 | """Initialize the database with required tables""" 51 | async with engine.begin() as conn: 52 | await conn.run_sync(Base.metadata.create_all) 53 | 54 | async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool: 55 | try: 56 | async with async_session() as session: 57 | # Check if record exists 58 | stmt = select(CanvasData).where(CanvasData.user_id == user_id) 59 | result = await session.execute(stmt) 60 | canvas_data = result.scalars().first() 61 | 62 | if canvas_data: 63 | # Update existing record 64 | canvas_data.data = data 65 | else: 66 | # Create new record 67 | canvas_data = CanvasData(user_id=user_id, data=data) 68 | session.add(canvas_data) 69 | 70 | await session.commit() 71 | return True 72 | except Exception as e: 73 | print(f"Error storing canvas data: {e}") 74 | return False 75 | 76 | async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]: 77 | try: 78 | async with async_session() as session: 79 | stmt = select(CanvasData).where(CanvasData.user_id == user_id) 80 | result = await session.execute(stmt) 81 | canvas_data = result.scalars().first() 82 | 83 | if canvas_data: 84 | return canvas_data.data 85 | return None 86 | except Exception as e: 87 | print(f"Error retrieving canvas data: {e}") 88 | return None 89 | -------------------------------------------------------------------------------- /src/backend/dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from fastapi import Request, HTTPException, Depends 3 | 4 | from config import sessions 5 | 6 | class SessionData: 7 | def __init__(self, access_token: str, token_data: dict): 8 | self.access_token = access_token 9 | self.token_data = token_data 10 | 11 | class AuthDependency: 12 | def __init__(self, auto_error: bool = True): 13 | self.auto_error = auto_error 14 | 15 | async def __call__(self, request: Request) -> Optional[SessionData]: 16 | session_id = request.cookies.get('session_id') 17 | 18 | if not session_id or session_id not in sessions: 19 | if self.auto_error: 20 | raise HTTPException( 21 | status_code=401, 22 | detail="Not authenticated", 23 | headers={"WWW-Authenticate": "Bearer"}, 24 | ) 25 | return None 26 | 27 | session = sessions[session_id] 28 | return SessionData( 29 | access_token=session.get('access_token'), 30 | token_data=session 31 | ) 32 | 33 | # Create instances for use in route handlers 34 | require_auth = AuthDependency(auto_error=True) 35 | optional_auth = AuthDependency(auto_error=False) 36 | -------------------------------------------------------------------------------- /src/backend/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import asynccontextmanager 3 | from typing import Optional 4 | 5 | from fastapi import FastAPI, Request, Depends 6 | from fastapi.responses import FileResponse 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from fastapi.staticfiles import StaticFiles 9 | from dotenv import load_dotenv 10 | import posthog 11 | 12 | load_dotenv() 13 | 14 | POSTHOG_API_KEY = os.environ.get("VITE_PUBLIC_POSTHOG_KEY") 15 | POSTHOG_HOST = os.environ.get("VITE_PUBLIC_POSTHOG_HOST") 16 | 17 | if POSTHOG_API_KEY: 18 | posthog.project_api_key = POSTHOG_API_KEY 19 | posthog.host = POSTHOG_HOST 20 | 21 | from db import init_db 22 | from config import STATIC_DIR, ASSETS_DIR 23 | from dependencies import SessionData, optional_auth 24 | from routers.auth import auth_router 25 | from routers.canvas import canvas_router 26 | from routers.user import user_router 27 | from routers.workspace import workspace_router 28 | 29 | @asynccontextmanager 30 | async def lifespan(_: FastAPI): 31 | await init_db() 32 | print("Database connection established successfully") 33 | yield 34 | 35 | app = FastAPI(lifespan=lifespan) 36 | 37 | # CORS middleware setup 38 | app.add_middleware( 39 | CORSMiddleware, 40 | allow_origins=["*"], 41 | allow_credentials=True, 42 | allow_methods=["*"], 43 | allow_headers=["*"], 44 | ) 45 | 46 | app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets") 47 | app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") 48 | 49 | @app.get("/") 50 | async def read_root(request: Request, auth: Optional[SessionData] = Depends(optional_auth)): 51 | return FileResponse(os.path.join(STATIC_DIR, "index.html")) 52 | 53 | # Include routers in the main app with the /api prefix 54 | app.include_router(auth_router, prefix="/auth") 55 | app.include_router(canvas_router, prefix="/api/canvas") 56 | app.include_router(user_router, prefix="/api/user") 57 | app.include_router(workspace_router, prefix="/api/workspace") 58 | 59 | if __name__ == "__main__": 60 | import uvicorn 61 | uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) 62 | -------------------------------------------------------------------------------- /src/backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn 3 | httpx 4 | jinja2 5 | asyncpg 6 | python-dotenv 7 | PyJWT 8 | requests 9 | sqlalchemy 10 | posthog 11 | -------------------------------------------------------------------------------- /src/backend/routers/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the routers directory a Python package 2 | -------------------------------------------------------------------------------- /src/backend/routers/auth.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import jwt 3 | import httpx 4 | from fastapi import APIRouter, Request, HTTPException, Depends 5 | from fastapi.responses import RedirectResponse, FileResponse 6 | import os 7 | 8 | from config import get_auth_url, get_token_url, OIDC_CONFIG, sessions, STATIC_DIR 9 | from dependencies import SessionData, require_auth 10 | from coder import CoderAPI 11 | 12 | auth_router = APIRouter() 13 | coder_api = CoderAPI() 14 | 15 | @auth_router.get("/login") 16 | async def login(request: Request, kc_idp_hint: str = None, popup: str = None): 17 | session_id = secrets.token_urlsafe(32) 18 | auth_url = get_auth_url() 19 | state = "popup" if popup == "1" else "default" 20 | if kc_idp_hint: 21 | auth_url = f"{auth_url}&kc_idp_hint={kc_idp_hint}" 22 | # Add state param to OIDC URL 23 | auth_url = f"{auth_url}&state={state}" 24 | response = RedirectResponse(auth_url) 25 | response.set_cookie('session_id', session_id) 26 | 27 | return response 28 | 29 | @auth_router.get("/callback") 30 | async def callback(request: Request, code: str, state: str = "default"): 31 | session_id = request.cookies.get('session_id') 32 | if not session_id: 33 | raise HTTPException(status_code=400, detail="No session") 34 | 35 | # Exchange code for token 36 | async with httpx.AsyncClient() as client: 37 | token_response = await client.post( 38 | get_token_url(), 39 | data={ 40 | 'grant_type': 'authorization_code', 41 | 'client_id': OIDC_CONFIG['client_id'], 42 | 'client_secret': OIDC_CONFIG['client_secret'], 43 | 'code': code, 44 | 'redirect_uri': OIDC_CONFIG['redirect_uri'] 45 | } 46 | ) 47 | 48 | if token_response.status_code != 200: 49 | raise HTTPException(status_code=400, detail="Auth failed") 50 | 51 | sessions[session_id] = token_response.json() 52 | access_token = token_response.json()['access_token'] 53 | user_info = jwt.decode(access_token, options={"verify_signature": False}) 54 | 55 | try: 56 | user_data, _ = coder_api.ensure_user_exists( 57 | user_info 58 | ) 59 | coder_api.ensure_workspace_exists(user_data['username']) 60 | except Exception as e: 61 | print(f"Error in user/workspace setup: {str(e)}") 62 | # Continue with login even if Coder API fails 63 | 64 | if state == "popup": 65 | return FileResponse(os.path.join(STATIC_DIR, "auth/popup-close.html")) 66 | else: 67 | return RedirectResponse('/') 68 | 69 | @auth_router.get("/logout") 70 | async def logout(request: Request): 71 | session_id = request.cookies.get('session_id') 72 | if session_id in sessions: 73 | del sessions[session_id] 74 | 75 | # Create a response that doesn't redirect but still clears the cookie 76 | from fastapi.responses import JSONResponse 77 | response = JSONResponse({"status": "success", "message": "Logged out successfully"}) 78 | 79 | # Clear the session_id cookie with all necessary parameters 80 | response.delete_cookie( 81 | key="session_id", 82 | path="/", 83 | domain=None, # Use None to match the current domain 84 | secure=request.url.scheme == "https", 85 | httponly=True 86 | ) 87 | 88 | return response 89 | -------------------------------------------------------------------------------- /src/backend/routers/canvas.py: -------------------------------------------------------------------------------- 1 | import json 2 | import jwt 3 | from typing import Dict, Any 4 | from fastapi import APIRouter, HTTPException, Depends, Request 5 | from fastapi.responses import JSONResponse 6 | 7 | from dependencies import SessionData, require_auth 8 | from db import store_canvas_data, get_canvas_data 9 | import posthog 10 | 11 | canvas_router = APIRouter() 12 | 13 | def get_default_canvas_data(): 14 | try: 15 | with open("default_canvas.json", "r") as f: 16 | return json.load(f) 17 | except Exception as e: 18 | raise HTTPException( 19 | status_code=500, 20 | detail=f"Failed to load default canvas: {str(e)}" 21 | ) 22 | 23 | @canvas_router.get("/default") 24 | async def get_default_canvas(auth: SessionData = Depends(require_auth)): 25 | try: 26 | with open("default_canvas.json", "r") as f: 27 | canvas_data = json.load(f) 28 | return canvas_data 29 | except Exception as e: 30 | return JSONResponse( 31 | status_code=500, 32 | content={"error": f"Failed to load default canvas: {str(e)}"} 33 | ) 34 | 35 | @canvas_router.post("") 36 | async def save_canvas(data: Dict[str, Any], auth: SessionData = Depends(require_auth), request: Request = None): 37 | access_token = auth.token_data.get("access_token") 38 | decoded = jwt.decode(access_token, options={"verify_signature": False}) 39 | user_id = decoded["sub"] 40 | success = await store_canvas_data(user_id, data) 41 | if not success: 42 | raise HTTPException(status_code=500, detail="Failed to save canvas data") 43 | # PostHog analytics: capture canvas_saved event 44 | try: 45 | app_state = data.get("appState", {}) 46 | width = app_state.get("width") 47 | height = app_state.get("height") 48 | zoom = app_state.get("zoom", {}).get("value") 49 | full_url = None 50 | if request: 51 | full_url = str(request.base_url).rstrip("/") + str(request.url.path) 52 | full_url = full_url.replace("http://", "https://") 53 | posthog.capture( 54 | distinct_id=user_id, 55 | event="canvas_saved", 56 | properties={ 57 | "pad_width": width, 58 | "pad_height": height, 59 | "pad_zoom": zoom, 60 | "$current_url": full_url, 61 | } 62 | ) 63 | except Exception as e: 64 | print(f"Error capturing canvas_saved event: {str(e)}") 65 | pass 66 | return {"status": "success"} 67 | 68 | @canvas_router.get("") 69 | async def get_canvas(auth: SessionData = Depends(require_auth)): 70 | access_token = auth.token_data.get("access_token") 71 | decoded = jwt.decode(access_token, options={"verify_signature": False}) 72 | user_id = decoded["sub"] 73 | data = await get_canvas_data(user_id) 74 | if data is None: 75 | return get_default_canvas_data() 76 | return data 77 | -------------------------------------------------------------------------------- /src/backend/routers/user.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from fastapi import APIRouter, Depends, Request 3 | import posthog 4 | 5 | from dependencies import SessionData, require_auth 6 | 7 | user_router = APIRouter() 8 | 9 | @user_router.get("/me") 10 | async def get_user_info(auth: SessionData = Depends(require_auth), request: Request = None): 11 | token_data = auth.token_data 12 | access_token = token_data.get("access_token") 13 | 14 | decoded = jwt.decode(access_token, options={"verify_signature": False}) 15 | 16 | # Build full URL (mirroring canvas.py logic) 17 | full_url = None 18 | if request: 19 | full_url = str(request.base_url).rstrip("/") + str(request.url.path) 20 | full_url = full_url.replace("http://", "https://") 21 | 22 | # Identify user in PostHog (mirrors frontend identify) 23 | posthog.identify( 24 | distinct_id=decoded["sub"], 25 | properties={ 26 | "email": decoded.get("email", ""), 27 | "username": decoded.get("preferred_username", ""), 28 | "name": decoded.get("name", ""), 29 | "given_name": decoded.get("given_name", ""), 30 | "family_name": decoded.get("family_name", ""), 31 | "email_verified": decoded.get("email_verified", False), 32 | "$current_url": full_url 33 | } 34 | ) 35 | 36 | return { 37 | "id": decoded["sub"], # Unique user ID 38 | "email": decoded.get("email", ""), 39 | "username": decoded.get("preferred_username", ""), 40 | "name": decoded.get("name", ""), 41 | "given_name": decoded.get("given_name", ""), 42 | "family_name": decoded.get("family_name", ""), 43 | "email_verified": decoded.get("email_verified", False) 44 | } 45 | -------------------------------------------------------------------------------- /src/backend/routers/workspace.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | from fastapi import APIRouter, HTTPException, Depends 3 | from fastapi.responses import JSONResponse 4 | from pydantic import BaseModel 5 | import jwt 6 | import os 7 | 8 | from dependencies import SessionData, require_auth 9 | from coder import CoderAPI 10 | 11 | workspace_router = APIRouter() 12 | coder_api = CoderAPI() 13 | 14 | class WorkspaceState(BaseModel): 15 | state: str 16 | workspace_id: str 17 | username: str 18 | base_url: str 19 | agent: str 20 | 21 | @workspace_router.get("/state", response_model=WorkspaceState) 22 | async def get_workspace_state(auth: SessionData = Depends(require_auth)): 23 | """ 24 | Get the current state of the user's workspace 25 | """ 26 | # Get user info from token 27 | access_token = auth.token_data.get("access_token") 28 | decoded = jwt.decode(access_token, options={"verify_signature": False}) 29 | username = decoded.get("preferred_username") 30 | email = decoded.get("email") 31 | 32 | # Get user's workspaces 33 | user = coder_api.get_user_by_email(email) 34 | username = user.get('username', None) 35 | if not username: 36 | raise HTTPException(status_code=404, detail="User not found") 37 | 38 | workspace = coder_api.get_workspace_status_for_user(username) 39 | 40 | if not workspace: 41 | raise HTTPException(status_code=404, detail="No workspace found for user") 42 | 43 | #states can be: 44 | #starting 45 | #running 46 | #stopping 47 | #stopped 48 | #error 49 | 50 | return WorkspaceState( 51 | state=workspace.get('latest_build', {}).get('status', 'error'), 52 | workspace_id=workspace.get('latest_build', {}).get('workspace_name', ''), 53 | username=username, 54 | base_url=os.getenv("CODER_URL", ""), 55 | agent="main" 56 | ) 57 | 58 | @workspace_router.post("/start") 59 | async def start_workspace(auth: SessionData = Depends(require_auth)): 60 | """ 61 | Start a workspace for the authenticated user 62 | """ 63 | # Get user info from token 64 | access_token = auth.token_data.get("access_token") 65 | decoded = jwt.decode(access_token, options={"verify_signature": False}) 66 | email = decoded.get("email") 67 | 68 | user = coder_api.get_user_by_email(email) 69 | username = user.get('username', None) 70 | if not username: 71 | raise HTTPException(status_code=404, detail="User not found") 72 | # Get user's workspace 73 | workspace = coder_api.get_workspace_status_for_user(username) 74 | if not workspace: 75 | raise HTTPException(status_code=404, detail="No workspace found for user") 76 | 77 | # Start the workspace 78 | try: 79 | response = coder_api.start_workspace(workspace["id"]) 80 | return JSONResponse(content=response) 81 | except Exception as e: 82 | raise HTTPException(status_code=500, detail=str(e)) 83 | 84 | @workspace_router.post("/stop") 85 | async def stop_workspace(auth: SessionData = Depends(require_auth)): 86 | """ 87 | Stop a workspace for the authenticated user 88 | """ 89 | # Get user info from token 90 | access_token = auth.token_data.get("access_token") 91 | decoded = jwt.decode(access_token, options={"verify_signature": False}) 92 | email = decoded.get("email") 93 | 94 | user = coder_api.get_user_by_email(email) 95 | username = user.get('username', None) 96 | if not username: 97 | raise HTTPException(status_code=404, detail="User not found") 98 | # Get user's workspace 99 | workspace = coder_api.get_workspace_status_for_user(username) 100 | if not workspace: 101 | raise HTTPException(status_code=404, detail="No workspace found for user") 102 | 103 | # Stop the workspace 104 | try: 105 | response = coder_api.stop_workspace(workspace["id"]) 106 | return JSONResponse(content=response) 107 | except Exception as e: 108 | raise HTTPException(status_code=500, detail=str(e)) 109 | 110 | -------------------------------------------------------------------------------- /src/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | Pad.ws 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/frontend/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | import posthog from "./src/utils/posthog"; 5 | import { PostHogProvider } from 'posthog-js/react'; 6 | 7 | import { QueryClientProvider } from '@tanstack/react-query'; 8 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 9 | import { queryClient } from './src/api/queryClient'; 10 | 11 | import "@atyrode/excalidraw/index.css"; 12 | import "./src/styles/index.scss"; 13 | 14 | import type * as TExcalidraw from "@atyrode/excalidraw"; 15 | 16 | import App from "./src/App"; 17 | import AuthGate from "./src/AuthGate"; 18 | 19 | 20 | declare global { 21 | interface Window { 22 | ExcalidrawLib: typeof TExcalidraw; 23 | } 24 | } 25 | 26 | async function initApp() { 27 | const rootElement = document.getElementById("root")!; 28 | const root = createRoot(rootElement); 29 | const { Excalidraw } = window.ExcalidrawLib; 30 | root.render( 31 | 32 | 33 | 34 | 35 | { }} 37 | excalidrawLib={window.ExcalidrawLib} 38 | > 39 | 40 | 41 | 42 | 43 | 44 | 45 | , 46 | ); 47 | } 48 | 49 | initApp().catch(console.error); 50 | -------------------------------------------------------------------------------- /src/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-script-in-browser", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@atyrode/excalidraw": "^0.18.0-1", 7 | "@monaco-editor/react": "^4.7.0", 8 | "@tanstack/react-query": "^5.74.3", 9 | "@tanstack/react-query-devtools": "^5.74.3", 10 | "browser-fs-access": "0.29.1", 11 | "lucide-react": "^0.488.0", 12 | "posthog-js": "^1.236.0", 13 | "react": "19.0.0", 14 | "react-dom": "19.0.0" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^22.14.0", 18 | "typescript": "^5", 19 | "vite": "5.2.0" 20 | }, 21 | "scripts": { 22 | "start": "vite", 23 | "build": "vite build" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/frontend/public/assets/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pad-ws/pad.ws/8bb75f9035e28bcb9c0e67f94e83c3fb20da6b04/src/frontend/public/assets/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf -------------------------------------------------------------------------------- /src/frontend/public/assets/fonts/Roboto-VariableFont_wdth,wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pad-ws/pad.ws/8bb75f9035e28bcb9c0e67f94e83c3fb20da6b04/src/frontend/public/assets/fonts/Roboto-VariableFont_wdth,wght.ttf -------------------------------------------------------------------------------- /src/frontend/public/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pad-ws/pad.ws/8bb75f9035e28bcb9c0e67f94e83c3fb20da6b04/src/frontend/public/assets/images/favicon.png -------------------------------------------------------------------------------- /src/frontend/public/auth/popup-close.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Authentication Complete 6 | 23 | 24 | 25 |
Authentication complete! You may close this window.
26 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect, useRef } from "react"; 2 | import { useCanvas, useDefaultCanvas, useUserProfile } from "./api/hooks"; 3 | import { ExcalidrawWrapper } from "./ExcalidrawWrapper"; 4 | import { debounce } from "./utils/debounce"; 5 | import { capture } from "./utils/posthog"; 6 | import posthog from "./utils/posthog"; 7 | import { useSaveCanvas } from "./api/hooks"; 8 | import type * as TExcalidraw from "@atyrode/excalidraw"; 9 | import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types"; 10 | import type { ExcalidrawImperativeAPI, AppState } from "@atyrode/excalidraw/types"; 11 | import { useAuthCheck } from "./api/hooks"; 12 | 13 | export interface AppProps { 14 | useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; 15 | customArgs?: any[]; 16 | children?: React.ReactNode; 17 | excalidrawLib: typeof TExcalidraw; 18 | } 19 | 20 | export default function App({ 21 | useCustom, 22 | customArgs, 23 | children, 24 | excalidrawLib, 25 | }: AppProps) { 26 | const { useHandleLibrary, MainMenu } = excalidrawLib; 27 | 28 | const { data: isAuthenticated, isLoading: isAuthLoading } = useAuthCheck(); 29 | const { data: userProfile } = useUserProfile(); 30 | 31 | // Only enable canvas queries if authenticated and not loading 32 | const { data: canvasData } = useCanvas({ 33 | queryKey: ['canvas'], 34 | enabled: isAuthenticated === true && !isAuthLoading, 35 | retry: 1, 36 | }); 37 | 38 | // Excalidraw API ref 39 | const [excalidrawAPI, setExcalidrawAPI] = useState(null); 40 | useCustom(excalidrawAPI, customArgs); 41 | useHandleLibrary({ excalidrawAPI }); 42 | 43 | function normalizeCanvasData(data: any) { 44 | if (!data) return data; 45 | const appState = { ...data.appState }; 46 | appState.width = undefined; 47 | if ("width" in appState) { 48 | delete appState.width; 49 | } 50 | if ("height" in appState) { 51 | delete appState.height; 52 | } 53 | if (!(appState.collaborators instanceof Map)) { 54 | appState.collaborators = new Map(); 55 | } 56 | return { ...data, appState }; 57 | } 58 | 59 | useEffect(() => { 60 | if (excalidrawAPI && canvasData) { 61 | excalidrawAPI.updateScene(normalizeCanvasData(canvasData)); 62 | } 63 | }, [excalidrawAPI, canvasData]); 64 | 65 | const { mutate: saveCanvas } = useSaveCanvas({ 66 | onSuccess: () => { 67 | console.debug("[pad.ws] Canvas saved to database successfully"); 68 | }, 69 | onError: (error) => { 70 | console.error("[pad.ws] Failed to save canvas to database:", error); 71 | } 72 | }); 73 | 74 | useEffect(() => { 75 | if (excalidrawAPI) { 76 | (window as any).excalidrawAPI = excalidrawAPI; 77 | capture('app_loaded'); 78 | } 79 | return () => { 80 | (window as any).excalidrawAPI = null; 81 | }; 82 | }, [excalidrawAPI]); 83 | 84 | const lastSentCanvasDataRef = useRef(""); 85 | 86 | const debouncedLogChange = useCallback( 87 | debounce( 88 | (elements: NonDeletedExcalidrawElement[], state: AppState, files: any) => { 89 | if (!isAuthenticated) return; 90 | 91 | const canvasData = { 92 | elements, 93 | appState: state, 94 | files 95 | }; 96 | 97 | const serialized = JSON.stringify(canvasData); 98 | if (serialized !== lastSentCanvasDataRef.current) { 99 | lastSentCanvasDataRef.current = serialized; 100 | saveCanvas(canvasData); 101 | } 102 | }, 103 | 1200 104 | ), 105 | [saveCanvas, isAuthenticated] 106 | ); 107 | 108 | useEffect(() => { 109 | if (userProfile?.id) { 110 | posthog.identify(userProfile.id); 111 | if (posthog.people && typeof posthog.people.set === "function") { 112 | const { 113 | id, // do not include in properties 114 | ...personProps 115 | } = userProfile; 116 | posthog.people.set(personProps); 117 | } 118 | } 119 | }, [userProfile]); 120 | 121 | return ( 122 | <> 123 | 129 | {children} 130 | 131 | 132 | 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /src/frontend/src/AuthGate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useAuthCheck } from "./api/hooks"; 3 | import AuthModal from "./auth/AuthModal"; 4 | 5 | /** 6 | * If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it. 7 | * 8 | * If authenticated, it silently primes the Coder OIDC session by loading 9 | * the OIDC callback endpoint in a hidden iframe. This is a workaround: 10 | * without this, users would see the Coder login screen when opening an embedded terminal. 11 | * 12 | * The iframe is removed as soon as it loads, or after a fallback timeout. 13 | */ 14 | export default function AuthGate({ children }: { children: React.ReactNode }) { 15 | const { data: isAuthenticated, isLoading } = useAuthCheck(); 16 | const [coderAuthDone, setCoderAuthDone] = useState(false); 17 | const iframeRef = useRef(null); 18 | const timeoutRef = useRef(null); 19 | 20 | useEffect(() => { 21 | // Only run the Coder OIDC priming once per session, after auth is confirmed 22 | if (isAuthenticated === true && !coderAuthDone) { 23 | const iframe = document.createElement("iframe"); 24 | iframe.style.display = "none"; 25 | iframe.src = "https://coder.pad.ws/api/v2/users/oidc/callback"; 26 | 27 | // Remove iframe as soon as it loads, or after 2s fallback 28 | const cleanup = () => { 29 | if (iframe.parentNode) iframe.parentNode.removeChild(iframe); 30 | setCoderAuthDone(true); 31 | }; 32 | 33 | iframe.onload = cleanup; 34 | document.body.appendChild(iframe); 35 | iframeRef.current = iframe; 36 | 37 | // Fallback: remove iframe after 5s if onload doesn't fire 38 | timeoutRef.current = window.setTimeout(cleanup, 5000); 39 | 40 | // Cleanup on unmount or re-run 41 | return () => { 42 | if (iframeRef.current && iframeRef.current.parentNode) { 43 | iframeRef.current.parentNode.removeChild(iframeRef.current); 44 | } 45 | if (timeoutRef.current) { 46 | clearTimeout(timeoutRef.current); 47 | } 48 | }; 49 | } 50 | // eslint-disable-next-line react-hooks/exhaustive-deps 51 | }, [isAuthenticated, coderAuthDone]); 52 | 53 | // Always render children; overlay AuthModal if not authenticated 54 | return ( 55 | <> 56 | {children} 57 | {isAuthenticated === false && } 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/frontend/src/CustomEmbeddableRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; 3 | import type { AppState } from '@atyrode/excalidraw/types'; 4 | import { 5 | Dashboard, 6 | StateIndicator, 7 | ControlButton, 8 | HtmlEditor, 9 | Editor 10 | } from './pad'; 11 | import { ActionButton } from './pad/buttons'; 12 | 13 | export const renderCustomEmbeddable = ( 14 | element: NonDeleted, 15 | appState: AppState, 16 | excalidrawAPI?: any 17 | ) => { 18 | 19 | if (element.link && element.link.startsWith('!')) { 20 | let path = element.link.split('!')[1]; 21 | 22 | switch (path) { 23 | case 'html': 24 | return ; 25 | case 'editor': 26 | return ; 27 | case 'state': 28 | return ; 29 | case 'control': 30 | return ; 31 | case 'button': 32 | return ; 38 | case 'dashboard': 39 | return ; 40 | default: 41 | return null; 42 | } 43 | } else { 44 | return