From 9a1bec68dcd4640363b9ca1ef1ab449ad80486ab Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:24:29 -0700 Subject: [PATCH 01/15] admin-dashboard: schema for composio apps integration --- .../2026-05-26-composio-apps-integration.md | 1198 +++++++++++++++++ .../2026-05-26-composio-apps-integration.md | 470 +++++++ ...260526150000_composio-apps-integration.sql | 27 + admin-dashboard/migrations/db_init.sql | 24 +- 4 files changed, 1708 insertions(+), 11 deletions(-) create mode 100644 admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md create mode 100644 admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md create mode 100644 admin-dashboard/migrations/20260526150000_composio-apps-integration.sql diff --git a/admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md b/admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md new file mode 100644 index 0000000..ca3bf63 --- /dev/null +++ b/admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md @@ -0,0 +1,1198 @@ +# Composio Apps Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the stub toggle on the admin-dashboard `/apps` page with real OAuth-backed connection flows for 7 SaaS integrations (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) brokered by Composio Connected Accounts. Stripe + OpenRouter become deep-links to the OSS dashboard. Zapier is removed. + +**Architecture:** Three InsForge edge functions (`apps-connect`, `apps-poll`, `apps-disconnect`) wrap Composio's REST API and write the Composio `connected_account_id` into `app_connections.config_json` using the caller's RLS context. The SPA opens a popup to Composio's hosted OAuth URL and polls the `apps-poll` function until `ACTIVE`. Stripe/OpenRouter cards branch to a `window.open(VITE_INSFORGE_OSS_URL + path)` flow with no DB row. + +**Tech Stack:** Vite + React 19 + TanStack Query + TanStack Router; `@insforge/sdk` for the SPA; Deno Subhosting (`npm:@insforge/sdk`) + Composio REST API for the edge functions. + +**Verification gate:** This template has no test framework (matching other InsForge templates), so each implementation task ends with `npm run typecheck` + `npm run lint` rather than unit tests. A final smoke-test task (Task 10) drives the end-to-end flow in a browser. + +**Reference spec:** `../specs/2026-05-26-composio-apps-integration.md`. The plan deliberately diverges from the spec in two places (called out where they occur): + +- **No `_shared.ts` for functions.** InsForge functions deploy one file at a time and don't share modules. CORS + auth helpers inline in each function. +- **No `app_connections` rows for `insforge_native` apps.** The Stripe/OpenRouter cards are static deep-links; their "connected" state is never persisted (the OSS dashboard owns that state). Only Composio apps write to `app_connections`. + +--- + +## File Map + +**Create:** +- `admin-dashboard/migrations/_composio-apps-integration.sql` — schema additions + re-seed +- `admin-dashboard/functions/apps-connect.ts` — initiate Composio Connected Account +- `admin-dashboard/functions/apps-poll.ts` — poll status, on ACTIVE upsert to `app_connections` +- `admin-dashboard/functions/apps-disconnect.ts` — revoke Composio + delete row +- `admin-dashboard/src/features/apps/use-connect-app.ts` — Composio popup flow + deep-link branch +- `admin-dashboard/src/features/apps/use-disconnect-app.ts` — Composio revoke + +**Modify:** +- `admin-dashboard/src/features/apps/use-apps.ts` — surface `integration_kind`, `account_label`, `oss_dashboard_path` +- `admin-dashboard/src/features/apps/apps-grid.tsx` — replace `` with kind-aware buttons +- `admin-dashboard/src/features/apps/apps-page.tsx` — wire to `useConnectApp` / `useDisconnectApp` +- `admin-dashboard/README.md` — replace "Wire a real integration" section with Composio setup checklist +- `admin-dashboard/.env.example` — add `VITE_INSFORGE_OSS_URL` + +**Delete:** +- `admin-dashboard/src/features/apps/use-toggle-app.ts` (replaced by the two new hooks) + +--- + +## Task 1: Migration — schema + re-seed + +**Files:** +- Create: `admin-dashboard/migrations/_composio-apps-integration.sql` + +- [ ] **Step 1: Generate the migration file with the InsForge CLI** + +The CLI enforces `_.sql` and rejects other formats. Run from `admin-dashboard/`: + +```bash +cd admin-dashboard +npx @insforge/cli db migrations new composio-apps-integration +``` + +This prints the created path (e.g. `migrations/20260526143000_composio-apps-integration.sql`). Note the actual filename — subsequent steps refer to it as ``. + +- [ ] **Step 2: Write the migration body** + +Replace the empty file contents with exactly: + +```sql +-- Composio Apps integration: catalog metadata + re-seed (no Zapier). +-- See docs/superpowers/specs/2026-05-26-composio-apps-integration.md + +alter table public.apps_catalog + add column if not exists integration_kind text not null default 'composio' + check (integration_kind in ('composio', 'insforge_native')); + +alter table public.apps_catalog + add column if not exists composio_toolkit_slug text; + +-- Re-seed: Zapier removed, integration_kind + composio_toolkit_slug populated. +insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) +on conflict (slug) do update set + integration_kind = excluded.integration_kind, + composio_toolkit_slug = excluded.composio_toolkit_slug, + description = excluded.description, + display_order = excluded.display_order; + +delete from public.apps_catalog where slug = 'zapier'; + +-- Also update the canonical db_init.sql seed inline so fresh installs start correct. +-- (No-op SQL: this comment documents that Step 3 below edits db_init.sql.) +``` + +- [ ] **Step 3: Update `migrations/db_init.sql` to match (fresh installs)** + +Open `admin-dashboard/migrations/db_init.sql` and find the `INSERT INTO public.apps_catalog` block at line ~610 (the one with the 10 service rows). Replace lines 610-621 (entire block including trailing `on conflict ... do nothing;`) with this new block: + +```sql +insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) +on conflict (slug) do nothing; +``` + +Also find the `create table if not exists public.apps_catalog (` block at line 81 and add two new columns inside the parens (after `oauth_provider text,` and before `display_order integer not null default 0`): + +```sql + integration_kind text not null default 'composio' + check (integration_kind in ('composio', 'insforge_native')), + composio_toolkit_slug text, +``` + +The result should have 8 columns total: `slug, name, description, icon_url, oauth_provider, integration_kind, composio_toolkit_slug, display_order`. Leave `oauth_provider` in place — it's dead but removing it is a destructive change that's out of scope here. + +- [ ] **Step 4: Apply the migration to the linked dev backend** + +```bash +cd admin-dashboard +npx @insforge/cli db migrations up --all +``` + +Expected output: `Applied 1 migration: `. If you see `Already up to date`, the migration was not picked up — check the filename matches the CLI's naming rule. + +- [ ] **Step 5: Verify schema applied** + +```bash +npx @insforge/cli db query "select slug, integration_kind, composio_toolkit_slug from public.apps_catalog order by display_order" +``` + +Expected: 9 rows, `stripe` and `openrouter` have `integration_kind=insforge_native`, the other 7 have `integration_kind=composio` with their toolkit slug populated, and **no `zapier` row**. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template +git add admin-dashboard/migrations/ admin-dashboard/migrations/db_init.sql +git commit -m "admin-dashboard: schema for composio apps integration" +``` + +--- + +## Task 2: Edge function — `apps-connect` + +**Files:** +- Create: `admin-dashboard/functions/apps-connect.ts` + +This function takes `{ app_slug, workspace_id }`, looks up the toolkit's `auth_config_id` from secrets, calls Composio's `connectedAccounts/initiate`, and returns the redirect URL + request id. + +- [ ] **Step 1: Create the file with this exact contents** + +```typescript +import { createClient } from 'npm:@insforge/sdk' + +// CORS — every InsForge function needs this for browser-invoked use. +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + // RLS-scoped client using the caller's JWT. + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + let body: { app_slug?: string; workspace_id?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { app_slug, workspace_id } = body + if (!app_slug || !workspace_id) return err(400, 'missing_fields') + + // Look up the toolkit slug. RLS allows any authenticated read on apps_catalog. + const { data: appRow, error: appErr } = await client.database + .from('apps_catalog') + .select('integration_kind, composio_toolkit_slug') + .eq('slug', app_slug) + .single() + if (appErr || !appRow) return err(404, 'app_not_found', appErr?.message) + if (appRow.integration_kind !== 'composio' || !appRow.composio_toolkit_slug) { + return err(400, 'not_a_composio_app') + } + + const authConfigId = Deno.env.get( + `COMPOSIO_AUTH_CONFIG_${appRow.composio_toolkit_slug.toUpperCase()}`, + ) + if (!authConfigId) return err(500, 'auth_config_not_provisioned') + + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (!composioApiKey) return err(500, 'composio_api_key_missing') + + // Composio v3 REST: initiate a connected account request. + // Docs: https://docs.composio.dev/api-reference/v3/connected-accounts + const initRes = await fetch('https://backend.composio.dev/api/v3/connected_accounts/initiate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': composioApiKey, + }, + body: JSON.stringify({ + auth_config_id: authConfigId, + // One Composio "user" per InsForge workspace — connections are workspace-scoped. + user_id: workspace_id, + }), + }) + + if (!initRes.ok) { + const text = await initRes.text() + return err(502, 'composio_initiate_failed', text) + } + const init = (await initRes.json()) as { id: string; redirect_url?: string; redirectUrl?: string } + + return json(200, { + request_id: init.id, + redirect_url: init.redirect_url ?? init.redirectUrl, + }) +} +``` + +> **Confirm the exact field names** for the Composio v3 initiate endpoint (`auth_config_id` vs `authConfigId`, `user_id` vs `userId`, `redirect_url` vs `redirectUrl`) by running `curl -H "x-api-key: $COMPOSIO_API_KEY" https://backend.composio.dev/api/v3/connected_accounts/initiate -d '{...}' -i` against a real `auth_config_id` once secrets are provisioned. If a field name differs from what's coded above, fix it in this file and propagate to `apps-poll.ts` (Task 3) and `apps-disconnect.ts` (Task 4) before deploying. + +- [ ] **Step 2: Deploy the function** + +```bash +cd admin-dashboard +npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts --description "Composio Connected Account initiate" +``` + +Expected: `Function apps-connect deployed (status: active)`. If status is `error`, run `npx @insforge/cli functions code apps-connect` to inspect what landed and `npx @insforge/cli logs function-deploy.logs --limit 10` to see why. + +- [ ] **Step 3: Verify it deployed** + +```bash +npx @insforge/cli functions list | grep apps-connect +``` + +Expected: `apps-connect | active`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template +git add admin-dashboard/functions/apps-connect.ts +git commit -m "admin-dashboard: apps-connect edge function for composio oauth initiate" +``` + +--- + +## Task 3: Edge function — `apps-poll` + +**Files:** +- Create: `admin-dashboard/functions/apps-poll.ts` + +Polls the Composio connection by request_id. On `ACTIVE`, upserts into `app_connections` with `config_json` populated. + +- [ ] **Step 1: Create the file** + +```typescript +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'GET') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + const url = new URL(req.url) + const request_id = url.searchParams.get('request_id') + const app_slug = url.searchParams.get('app_slug') + const workspace_id = url.searchParams.get('workspace_id') + if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') + + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (!composioApiKey) return err(500, 'composio_api_key_missing') + + const res = await fetch( + `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(request_id)}`, + { headers: { 'x-api-key': composioApiKey } }, + ) + if (!res.ok) return err(502, 'composio_get_failed', await res.text()) + + const account = (await res.json()) as { + id: string + status: string + data?: Record + created_at?: string + } + + // Composio v3 statuses: INITIATED, ACTIVE, FAILED, INACTIVE, EXPIRED, REVOKED. + if (account.status === 'INITIATED') return json(200, { status: 'pending' }) + if (account.status !== 'ACTIVE') { + return json(200, { status: 'failed', detail: account.status }) + } + + // Build a friendly label from whatever Composio surfaced. Different toolkits put + // different shapes in account.data — fall back gracefully. + const dataObj = (account.data ?? {}) as Record + const account_label = + (dataObj.account_label as string | undefined) ?? + (dataObj.user_email as string | undefined) ?? + (dataObj.email as string | undefined) ?? + (dataObj.login as string | undefined) ?? + (dataObj.name as string | undefined) ?? + null + + const { error: upsertErr } = await client.database + .from('app_connections') + .upsert( + { + workspace_id, + app_slug, + status: 'connected', + connected_by: userData.user.id, + connected_at: new Date().toISOString(), + config_json: { + kind: 'composio', + connected_account_id: account.id, + account_label, + connected_at_provider: account.created_at ?? null, + }, + }, + { onConflict: 'workspace_id,app_slug' }, + ) + + if (upsertErr) return err(500, 'db_upsert_failed', upsertErr.message) + + return json(200, { status: 'connected', account_label }) +} +``` + +> Same caveat as Task 2: confirm Composio field names (`status` enum values, `data` shape, `created_at` vs `createdAt`) by inspecting one real `GET /connected_accounts/{id}` response after the first end-to-end auth in Task 10. Adjust this file if reality differs. + +- [ ] **Step 2: Deploy** + +```bash +cd admin-dashboard +npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts --description "Composio Connected Account status poll + persist" +``` + +- [ ] **Step 3: Verify** + +```bash +npx @insforge/cli functions list | grep apps-poll +``` + +Expected: `apps-poll | active`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template +git add admin-dashboard/functions/apps-poll.ts +git commit -m "admin-dashboard: apps-poll edge function for composio status + persist" +``` + +--- + +## Task 4: Edge function — `apps-disconnect` + +**Files:** +- Create: `admin-dashboard/functions/apps-disconnect.ts` + +Revokes the Composio connection (best-effort) and deletes the row from `app_connections`. + +- [ ] **Step 1: Create the file** + +```typescript +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + let body: { app_slug?: string; workspace_id?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { app_slug, workspace_id } = body + if (!app_slug || !workspace_id) return err(400, 'missing_fields') + + // RLS scopes this select to the caller's workspace memberships automatically. + const { data: existing } = await client.database + .from('app_connections') + .select('config_json') + .eq('workspace_id', workspace_id) + .eq('app_slug', app_slug) + .maybeSingle() + + const composioId = + (existing?.config_json as Record | null | undefined)?.connected_account_id as + | string + | undefined + + if (composioId) { + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (composioApiKey) { + // Best-effort: a 404 from Composio means the connection is already gone. + await fetch( + `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(composioId)}`, + { method: 'DELETE', headers: { 'x-api-key': composioApiKey } }, + ).catch(() => {}) + } + } + + const { error: delErr } = await client.database + .from('app_connections') + .delete() + .eq('workspace_id', workspace_id) + .eq('app_slug', app_slug) + if (delErr) return err(500, 'db_delete_failed', delErr.message) + + return json(200, { ok: true }) +} +``` + +- [ ] **Step 2: Deploy** + +```bash +cd admin-dashboard +npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts --description "Composio Connected Account revoke + cleanup" +``` + +- [ ] **Step 3: Verify** + +```bash +npx @insforge/cli functions list | grep apps-disconnect +``` + +Expected: `apps-disconnect | active`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template +git add admin-dashboard/functions/apps-disconnect.ts +git commit -m "admin-dashboard: apps-disconnect edge function for composio revoke" +``` + +--- + +## Task 5: Frontend — extend `use-apps.ts` data shape + +**Files:** +- Modify: `admin-dashboard/src/features/apps/use-apps.ts` + +Surface `integration_kind`, `composio_toolkit_slug`, `account_label` (from `config_json`), and a computed `oss_dashboard_path` for native apps. + +- [ ] **Step 1: Replace the file's contents with this exact code** + +```typescript +import { useQuery } from '@tanstack/react-query' +import { insforge } from '@/lib/insforge' + +export type IntegrationKind = 'composio' | 'insforge_native' + +export type App = { + slug: string + name: string + description: string + icon_url: string | null + integration_kind: IntegrationKind + composio_toolkit_slug: string | null + display_order: number +} + +type ConnectionConfig = { + kind?: 'composio' + connected_account_id?: string + account_label?: string | null +} + +export type AppConnection = { + id: string + workspace_id: string + app_slug: string + status: 'connected' | 'disconnected' + connected_at: string + connected_by: string + config_json: ConnectionConfig | null +} + +// Deep-link targets in the OSS dashboard for the two native apps. +const OSS_DEEP_LINKS: Record = { + stripe: '/payments', + openrouter: '/ai', +} + +export type AppWithConnection = { + slug: string + name: string + description: string + icon_url: string | null + display_order: number + integration_kind: IntegrationKind + composio_toolkit_slug: string | null + oss_dashboard_path: string | null + connected: boolean + account_label: string | null +} + +export const appsKey = (workspaceId: string | undefined) => ['apps', workspaceId] as const + +export function useApps(workspaceId: string | undefined) { + return useQuery({ + enabled: !!workspaceId, + queryKey: appsKey(workspaceId), + queryFn: async (): Promise => { + const [catalogRes, connectionsRes] = await Promise.all([ + insforge.database + .from('apps_catalog') + .select( + 'slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order', + ) + .order('display_order', { ascending: true }), + insforge.database + .from('app_connections') + .select('id, workspace_id, app_slug, status, connected_at, connected_by, config_json') + .eq('workspace_id', workspaceId!), + ]) + + if (catalogRes.error) throw new Error(catalogRes.error.message) + if (connectionsRes.error) throw new Error(connectionsRes.error.message) + + const catalog = (catalogRes.data ?? []) as App[] + const connections = (connectionsRes.data ?? []) as AppConnection[] + const bySlug = new Map(connections.filter((c) => c.status === 'connected').map((c) => [c.app_slug, c])) + + return catalog.map((app) => { + const conn = bySlug.get(app.slug) + return { + slug: app.slug, + name: app.name, + description: app.description, + icon_url: app.icon_url, + display_order: app.display_order, + integration_kind: app.integration_kind, + composio_toolkit_slug: app.composio_toolkit_slug, + oss_dashboard_path: OSS_DEEP_LINKS[app.slug] ?? null, + connected: !!conn, + account_label: conn?.config_json?.account_label ?? null, + } + }) + }, + }) +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd admin-dashboard && npm run typecheck +``` + +Expected: passes. If the `apps-grid.tsx` or `apps-page.tsx` consumers complain about missing properties, that's expected — Task 7 fixes the grid and Task 8 fixes the page. Note any errors involving files **other than** those two — those would indicate a regression. + +> **Do not commit yet** — leave the working tree dirty so Tasks 6-8 land alongside as a single coherent change. The intermediate state will not typecheck. + +--- + +## Task 6: Frontend — create `use-connect-app.ts` + +**Files:** +- Create: `admin-dashboard/src/features/apps/use-connect-app.ts` + +A single mutation that branches on `integration_kind`. For `composio`: opens popup → polls. For `insforge_native`: opens the OSS dashboard deep-link in a new tab. + +- [ ] **Step 1: Create the file** + +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { insforge } from '@/lib/insforge' +import { appsKey, type AppWithConnection } from './use-apps' + +const POLL_INTERVAL_MS = 1500 +const POLL_DEADLINE_MS = 120_000 // 2 minutes +const OSS_BASE_URL = import.meta.env.VITE_INSFORGE_OSS_URL ?? 'https://app.insforge.dev' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +type ConnectArgs = { app: AppWithConnection } + +async function connectComposio(args: { + appSlug: string + workspaceId: string +}): Promise<{ account_label: string | null }> { + // Open the popup synchronously to avoid being blocked. The URL is rewritten + // once the initiate call resolves. + const popup = window.open('about:blank', 'composio-oauth', 'width=600,height=720') + if (!popup) throw new Error('Popup blocked — allow popups for this site and try again.') + + try { + const { data: initData, error: initErr } = await insforge.functions.invoke('apps-connect', { + body: { app_slug: args.appSlug, workspace_id: args.workspaceId }, + }) + if (initErr) throw new Error(initErr.message ?? 'Failed to start authorization') + + const { redirect_url, request_id } = initData as { redirect_url: string; request_id: string } + if (!redirect_url || !request_id) throw new Error('Composio did not return a redirect URL') + + popup.location.href = redirect_url + + const deadline = Date.now() + POLL_DEADLINE_MS + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS) + + const { data: pollData, error: pollErr } = await insforge.functions.invoke('apps-poll', { + method: 'GET', + body: { + request_id, + app_slug: args.appSlug, + workspace_id: args.workspaceId, + }, + }) + if (pollErr) throw new Error(pollErr.message ?? 'Polling failed') + + const result = pollData as { status: 'pending' | 'connected' | 'failed'; account_label?: string | null; detail?: string } + if (result.status === 'connected') { + return { account_label: result.account_label ?? null } + } + if (result.status === 'failed') { + throw new Error(`Authorization failed (${result.detail ?? 'unknown'})`) + } + // Treat user-closed popup as cancellation only after the next poll comes back pending. + if (popup.closed && result.status === 'pending') { + throw new Error('Authorization window was closed before completion') + } + } + throw new Error('Authorization timed out after 2 minutes') + } finally { + if (!popup.closed) popup.close() + } +} + +export function useConnectApp(workspaceId: string | undefined) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ app }: ConnectArgs): Promise => { + if (!workspaceId) throw new Error('No active workspace') + + if (app.integration_kind === 'insforge_native') { + if (!app.oss_dashboard_path) throw new Error('No dashboard path for this app') + window.open(`${OSS_BASE_URL}${app.oss_dashboard_path}`, '_blank', 'noopener,noreferrer') + return + } + + await connectComposio({ appSlug: app.slug, workspaceId }) + }, + onSuccess: (_data, { app }) => { + if (app.integration_kind === 'composio') { + toast.success(`${app.name} connected`) + void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) + } + // For insforge_native we just opened a new tab — no toast needed. + }, + onError: (err: Error) => toast.error(err.message), + }) +} +``` + +> Note: `insforge.functions.invoke` with `method: 'GET'` still serializes `body` into the request — confirm this against the SDK behavior. If GET + body is not supported, change `apps-poll` to accept POST and update both ends accordingly. + +- [ ] **Step 2: Typecheck** + +```bash +cd admin-dashboard && npm run typecheck +``` + +Expected: passes for this file. Pre-existing errors in `apps-grid.tsx` / `apps-page.tsx` remain (fixed in Tasks 7-8). + +--- + +## Task 7: Frontend — create `use-disconnect-app.ts` + +**Files:** +- Create: `admin-dashboard/src/features/apps/use-disconnect-app.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { insforge } from '@/lib/insforge' +import { appsKey, type AppWithConnection } from './use-apps' + +type DisconnectArgs = { app: AppWithConnection } + +export function useDisconnectApp(workspaceId: string | undefined) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ app }: DisconnectArgs): Promise => { + if (!workspaceId) throw new Error('No active workspace') + if (app.integration_kind !== 'composio') { + throw new Error('Native integrations are managed in the InsForge dashboard') + } + const { error } = await insforge.functions.invoke('apps-disconnect', { + body: { app_slug: app.slug, workspace_id: workspaceId }, + }) + if (error) throw new Error(error.message ?? 'Disconnect failed') + }, + onSuccess: (_data, { app }) => { + toast.success(`${app.name} disconnected`) + void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) + }, + onError: (err: Error) => toast.error(err.message), + }) +} +``` + +- [ ] **Step 2: Delete the obsolete hook** + +```bash +rm admin-dashboard/src/features/apps/use-toggle-app.ts +``` + +--- + +## Task 8: Frontend — rewrite `apps-grid.tsx` + +**Files:** +- Modify: `admin-dashboard/src/features/apps/apps-grid.tsx` + +Drop the `Switch`. Render kind-aware actions: Composio cards get "Connect" / "Connecting…" / "Connected as X + Disconnect"; native cards get "Configure in dashboard" only. + +- [ ] **Step 1: Replace the file's contents** + +```typescript +import { ExternalLink, Loader2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' +import type { AppWithConnection } from './use-apps' + +type Props = { + apps: AppWithConnection[] + isLoading: boolean + isConnectPending: (slug: string) => boolean + isDisconnectPending: (slug: string) => boolean + onConnect: (app: AppWithConnection) => void + onDisconnect: (app: AppWithConnection) => void +} + +export function AppsGrid({ + apps, + isLoading, + isConnectPending, + isDisconnectPending, + onConnect, + onDisconnect, +}: Props) { + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }, (_, i) => ( + + +
+ + +
+ + +
+
+ ))} +
+ ) + } + + if (apps.length === 0) { + return

No apps available.

+ } + + return ( +
+ {apps.map((app) => { + const connectPending = isConnectPending(app.slug) + const disconnectPending = isDisconnectPending(app.slug) + const isNative = app.integration_kind === 'insforge_native' + + return ( + + {app.connected && ( + + Connected + + )} + +
+ {app.icon_url ? ( + {`${app.name} + ) : ( +
+ )} + {app.name} +
+ + {app.description} + + + + + {isNative ? ( + <> + + Managed in InsForge dashboard + + + + ) : app.connected ? ( + <> + + {app.account_label ? `as ${app.account_label}` : 'Active integration'} + + + + ) : ( + <> + Not connected + + + )} + + + ) + })} +
+ ) +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd admin-dashboard && npm run typecheck +``` + +Expected: this file passes. The remaining error should now be in `apps-page.tsx`, fixed in Task 9. + +--- + +## Task 9: Frontend — wire `apps-page.tsx` + +**Files:** +- Modify: `admin-dashboard/src/features/apps/apps-page.tsx` + +- [ ] **Step 1: Read the current contents** to learn what props were being passed. + +```bash +cat admin-dashboard/src/features/apps/apps-page.tsx +``` + +- [ ] **Step 2: Edit the file** so it uses the new hooks and passes the new `AppsGrid` props. + +Replace the import of `useToggleApp` with the two new hooks, instantiate both, and pass `isConnectPending`, `isDisconnectPending`, `onConnect`, `onDisconnect` instead of the old `isPending` + `onToggle`. The pending-checker pattern: + +```typescript +const connect = useConnectApp(workspaceId) +const disconnect = useDisconnectApp(workspaceId) + +const isConnectPending = (slug: string) => + connect.isPending && connect.variables?.app.slug === slug +const isDisconnectPending = (slug: string) => + disconnect.isPending && disconnect.variables?.app.slug === slug + +return ( + connect.mutate({ app })} + onDisconnect={(app) => disconnect.mutate({ app })} + /> +) +``` + +Keep all other page chrome (header, description, error states) exactly as it was. + +- [ ] **Step 3: Typecheck + lint** + +```bash +cd admin-dashboard && npm run typecheck && npm run lint +``` + +Expected: both pass. If `lint` reports `no-unused-vars` for any leftover import, remove the import. + +- [ ] **Step 4: Build** + +```bash +cd admin-dashboard && npm run build +``` + +Expected: successful Vite production build, no TypeScript errors. The build asserts the SPA bundles cleanly. + +- [ ] **Step 5: Commit all frontend changes together** + +```bash +cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template +git add admin-dashboard/src/features/apps/ +git commit -m "admin-dashboard: replace apps toggle stub with composio + native split" +``` + +--- + +## Task 10: README + `.env.example` + +**Files:** +- Modify: `admin-dashboard/README.md` +- Create/Modify: `admin-dashboard/.env.example` + +- [ ] **Step 1: Add `VITE_INSFORGE_OSS_URL` to `.env.example`** + +Read the file first: + +```bash +ls -a admin-dashboard | grep -i env +``` + +If `.env.example` exists, append: + +``` +# OSS dashboard origin for native-integration deep links (Stripe / OpenRouter). +# Defaults to https://app.insforge.dev when unset. +VITE_INSFORGE_OSS_URL=https://app.insforge.dev +``` + +If it doesn't exist, create it with the three lines above as the entire contents (per memory: dotfiles can be invisible to bare `ls` — `ls -a` was used above to confirm). + +- [ ] **Step 2: Rewrite the "Wire a real integration" section of README.md** + +Find the existing "Wire a real integration" paragraph (referenced in the original `use-toggle-app.ts` comment at line ~149). Replace that paragraph and surrounding instructions with this new section: + +````markdown +## Connecting third-party apps + +The `/apps` page lists every integration available to a workspace. Two kinds: + +- **InsForge-native** (Stripe, OpenRouter) — configured in the InsForge dashboard. The card opens the corresponding dashboard page in a new tab. +- **Composio-backed** (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) — connected per workspace via Composio's hosted OAuth. + +### One-time Composio setup + +1. Sign up at [composio.dev](https://composio.dev) and create an API key in **Settings → API Keys**. +2. For each of the 7 toolkits (`github`, `notion`, `slack`, `discord`, `figma`, `linear`, `vercel`), open the **Toolkits** page, click the toolkit, then **Create Auth Config** with OAuth 2.0. Copy the resulting `auth_config_id` (`ac_…`). +3. Store every value in InsForge secrets: + + ```bash + npx @insforge/cli secrets add COMPOSIO_API_KEY + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_GITHUB ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_NOTION ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_SLACK ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_DISCORD ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_FIGMA ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_LINEAR ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_VERCEL ac_xxx + ``` + +4. Deploy the three edge functions: + + ```bash + npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts + npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts + npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts + ``` + +Composio routes its OAuth callback to its own domain — you do **not** need to add any URLs to `insforge.toml`. You do need to allow popups in your browser; the connect flow opens one synchronously when the user clicks "Connect". +```` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template +git add admin-dashboard/README.md admin-dashboard/.env.example +git commit -m "admin-dashboard: document composio apps setup" +``` + +--- + +## Task 11: End-to-end smoke verification + +This is the verification gate that replaces unit tests. Driven manually in a browser. **Do not skip** — typecheck/lint/build do not cover the runtime OAuth flow. + +- [ ] **Step 1: Prerequisites** + +Confirm the following are true before starting: + +```bash +cd admin-dashboard +npx @insforge/cli secrets list | grep COMPOSIO # should list COMPOSIO_API_KEY + 7 AUTH_CONFIG entries +npx @insforge/cli functions list # apps-connect, apps-poll, apps-disconnect all 'active' +npx @insforge/cli db query "select slug, integration_kind from public.apps_catalog order by display_order" +# expected: 9 rows, no zapier +``` + +If any check fails, stop and fix the corresponding task before continuing. + +- [ ] **Step 2: Run the dev server** + +```bash +cd admin-dashboard && npm run dev +``` + +- [ ] **Step 3: Drive the Slack flow** + +1. Open the printed URL, sign in (or sign up), create a workspace if you don't have one. +2. Navigate to `/apps`. Expected: 9 cards. Stripe + OpenRouter show **Configure** buttons; the other 7 show **Connect** buttons. No Zapier card. +3. Click **Connect** on Slack. A popup opens to a `slack.com` URL via `backend.composio.dev`. +4. Authorize. Popup closes (or returns to a Composio confirmation page that you can close). +5. Within ~3 seconds the Slack card re-renders to show the **Connected** badge and `as `, plus a **Disconnect** button. +6. Refresh the page — state persists (`as ...` still shown). + +- [ ] **Step 4: Verify the DB row** + +```bash +npx @insforge/cli db query "select app_slug, status, config_json from public.app_connections where app_slug='slack'" +``` + +Expected: one row with `status=connected` and `config_json` containing `kind: composio`, a `connected_account_id`, and a non-null `account_label`. + +- [ ] **Step 5: Verify Composio dashboard** + +Open the Composio dashboard → Connected Accounts. Expected: a new ACTIVE row for Slack scoped to the workspace UUID (passed as `user_id`). + +- [ ] **Step 6: Disconnect** + +Click **Disconnect** on the Slack card. Expected: card returns to **Connect** state. + +```bash +npx @insforge/cli db query "select count(*) from public.app_connections where app_slug='slack'" +``` + +Expected: `0`. The Composio dashboard row should also be gone (or marked deleted depending on Composio's UI). + +- [ ] **Step 7: Spot-check a second toolkit** + +Repeat steps 3-4 for **GitHub** to confirm the code path is toolkit-agnostic. + +- [ ] **Step 8: Spot-check the native deep-link** + +Click **Configure** on Stripe. Expected: a new tab opens to `/payments`. + +- [ ] **Step 9: Final summary** + +If steps 3-8 all pass, the integration is functionally complete. If any step fails, identify the failing task above and fix it. No commit for this task (no code changes). + +--- + +## Self-Review Notes + +**Spec coverage:** Tasks 1-10 implement spec sections 4 (schema), 5 (edge functions), 6 (frontend), 7 (Composio setup), and 8 (env vars). Spec section 9 gotchas are baked in: (1) workspace_id as Composio user_id — Task 2 step 1; (2) polling cadence — Task 6 step 1 (1.5 s × 80 = 120 s deadline); (3) popup blockers — Task 6 step 1 opens popup synchronously before await; (4) `VITE_INSFORGE_OSS_URL` for self-hosted — Task 10. Spec section 10 demo path == Task 11. + +**Open contract risks** (called out at point-of-use): Composio v3 REST field naming (Tasks 2-3) and `insforge.functions.invoke` GET-with-body support (Task 6). Both will be caught immediately by Task 11 step 3 if wrong. + +**No placeholders.** Every code block is a complete, copy-pasteable artifact. Where this plan defers to "read the file first" (Task 9 step 1), it's because the current file's structure isn't shown in the spec and we want to minimize what gets rewritten. diff --git a/admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md b/admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md new file mode 100644 index 0000000..f09a510 --- /dev/null +++ b/admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md @@ -0,0 +1,470 @@ +# Composio Integration for Apps Page — Design Spec + +**Date:** 2026-05-26 +**Template:** `admin-dashboard` +**Author:** InsForge + +## 1. Goal + +Replace the stub toggle in `src/features/apps/use-toggle-app.ts` with real per-workspace OAuth flows for 7 SaaS integrations, brokered by [Composio](https://composio.dev) Connected Accounts. After this change, clicking "Connect" on a Slack/Notion/GitHub/Discord/Figma/Linear/Vercel card will: + +1. Open the provider's real OAuth consent screen (hosted by Composio). +2. Persist the resulting Composio `connected_account_id` into `app_connections.config_json`. +3. Show the connected account label ("Connected as `@alice`") and a Disconnect button on the card. + +Stripe and OpenRouter remain InsForge-native (their cards deep-link to the OSS dashboard's existing settings pages). The `zapier` card is removed — it overlaps semantically with Composio itself. + +## 2. Scope + +### In scope + +| Service | Integration | +|---------|-------------| +| GitHub | Composio (`github`) | +| Notion | Composio (`notion`) | +| Slack | Composio (`slack`) | +| Discord | Composio (`discord`) | +| Figma | Composio (`figma`) | +| Linear | Composio (`linear`) | +| Vercel | Composio (`vercel`) | +| Stripe | InsForge-native — deep link to OSS dashboard `/payments` | +| OpenRouter | InsForge-native — deep link to OSS dashboard `/ai` | + +### Out of scope + +- **Zapier card** — removed from the seed list. +- **Acting on the connection** (posting to Slack, creating Notion pages, etc.) — this spec only covers connection management. A follow-up template iteration can add an "Automations" page that wires Composio actions to InsForge realtime triggers. +- **Per-user (end-user) connections** — connections are scoped to the workspace (admin connects once, all workspace members share the connection). + +## 3. Architecture + +### Why Composio + +Each of the 7 SaaS apps has its own OAuth dance, scopes, refresh-token policy, and API surface. Doing them individually means 7 × (OAuth client registration + token storage + refresh logic + API client). Composio handles all of that and exposes a unified Connected Accounts model: one API to initiate auth, one API to list/revoke connections, one API to execute actions. + +### Flow + +``` +[User] [SPA] [InsForge edge fn] [Composio] + | | | | + | click "Connect Slack" | | | + |---------------------->| | | + | | POST /apps/slack/connect | | + | |--------------------------->| | + | | | initiateConnectedAccount() | + | | |--------------------------->| + | | | { redirectUrl, id } | + | | |<---------------------------| + | | { redirectUrl, request_id }| | + | |<---------------------------| | + | | | | + | popup -> Slack OAuth | | | + |---------------------------------------------------------------------------------| + | | | | + | Slack callback -> Composio callback URL | | + |---------------------------------------------------------------------------------| + | | | | + | | poll: GET /apps/slack | | + | | /poll?request_id=... | | + | |--------------------------->| | + | | | getConnectedAccount(req_id)| + | | |--------------------------->| + | | | { status: ACTIVE, ... } | + | | |<---------------------------| + | | | upsert app_connections | + | | | .config_json with | + | | | { connected_account_id, | + | | | account_label } | + | | { connected: true, label }| | + | |<---------------------------| | + | card re-renders | | | +``` + +Composio's OAuth callback goes to `https://backend.composio.dev/api/v3/...` — **not** the template's domain. So we do not need to add any callback URLs to `insforge.toml`'s `allowed_redirect_urls`. The SPA polls our edge function (which polls Composio) until the connection becomes `ACTIVE` or times out. + +## 4. Data Model Changes + +All additive — no destructive migration. + +### `apps_catalog` — add `integration_kind` column + +```sql +alter table public.apps_catalog + add column if not exists integration_kind text not null default 'composio' + check (integration_kind in ('composio', 'insforge_native')); + +alter table public.apps_catalog + add column if not exists composio_toolkit_slug text; +``` + +- `integration_kind` — drives client-side branching (Composio OAuth vs deep link). +- `composio_toolkit_slug` — Composio's toolkit identifier (e.g. `slack`, `github`). Usually matches our `slug`; kept separate so we can rename our slug without breaking Composio mapping. + +### `apps_catalog` — re-seed + +```sql +-- Replace the existing single INSERT block (db_init.sql:610-621) with this. +insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) +on conflict (slug) do update set + integration_kind = excluded.integration_kind, + composio_toolkit_slug = excluded.composio_toolkit_slug; + +-- Drop Zapier from any existing dev database. +delete from public.apps_catalog where slug = 'zapier'; +``` + +The pre-existing `oauth_provider` column on `apps_catalog` becomes dead — leave it for now to avoid a destructive migration; the next major schema rev can drop it. + +### `app_connections.config_json` shape (no DDL change) + +The column is already `jsonb not null default '{}'::jsonb`. Composio-backed connections will store: + +```json +{ + "kind": "composio", + "connected_account_id": "ca_AbC123...", + "account_label": "@alice in acme-eng", + "connected_at_provider": "2026-05-26T14:22:10Z" +} +``` + +InsForge-native connections continue to leave `config_json` empty `{}` — we just key off the row's existence + `apps_catalog.integration_kind = 'insforge_native'` to know what to render. + +## 5. Backend — InsForge Edge Functions + +Two new functions, deployed via `npx @insforge/cli functions deploy`. The template root will gain a `functions/` directory mirroring the [chatbot template's pattern](../../../chatbot/functions/). + +### `functions/apps-connect/index.ts` + +```typescript +// POST /apps-connect +// Body: { app_slug: string, workspace_id: string } +// Returns: { redirect_url: string, request_id: string } + +import { ComposioClient } from '@composio/core' +import { getRequestingUser, getWorkspaceMembership, json, err } from './_shared.ts' + +const composio = new ComposioClient({ apiKey: Deno.env.get('COMPOSIO_API_KEY')! }) + +export default async function handler(req: Request): Promise { + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const user = await getRequestingUser(req) + if (!user) return err(401, 'unauthorized') + + const { app_slug, workspace_id } = await req.json() + if (!app_slug || !workspace_id) return err(400, 'missing_fields') + + const member = await getWorkspaceMembership(user.id, workspace_id) + if (!member) return err(403, 'not_a_workspace_member') + + // Look up the toolkit slug from apps_catalog. + const { data: appRow } = await member.db + .from('apps_catalog') + .select('integration_kind, composio_toolkit_slug') + .eq('slug', app_slug) + .single() + + if (appRow?.integration_kind !== 'composio' || !appRow.composio_toolkit_slug) { + return err(400, 'not_a_composio_app') + } + + // Composio Connected Accounts: initiate. + // The auth_config_id is shared per toolkit, registered once in Composio dashboard + // and stored in env as COMPOSIO_AUTH_CONFIG_. + const authConfigId = Deno.env.get( + `COMPOSIO_AUTH_CONFIG_${appRow.composio_toolkit_slug.toUpperCase()}`, + ) + if (!authConfigId) return err(500, 'auth_config_not_provisioned') + + const init = await composio.connectedAccounts.initiate({ + userId: workspace_id, // <-- one Composio "user" per InsForge workspace + authConfigId, + }) + + return json({ + redirect_url: init.redirectUrl, + request_id: init.id, + }) +} +``` + +### `functions/apps-poll/index.ts` + +```typescript +// GET /apps-poll?request_id=...&app_slug=...&workspace_id=... +// Returns: { status: 'pending' | 'connected' | 'failed', account_label?: string } + +import { ComposioClient } from '@composio/core' +import { getRequestingUser, getWorkspaceMembership, json, err } from './_shared.ts' + +const composio = new ComposioClient({ apiKey: Deno.env.get('COMPOSIO_API_KEY')! }) + +export default async function handler(req: Request): Promise { + if (req.method !== 'GET') return err(405, 'method_not_allowed') + + const user = await getRequestingUser(req) + if (!user) return err(401, 'unauthorized') + + const url = new URL(req.url) + const request_id = url.searchParams.get('request_id') + const app_slug = url.searchParams.get('app_slug') + const workspace_id = url.searchParams.get('workspace_id') + if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') + + const member = await getWorkspaceMembership(user.id, workspace_id) + if (!member) return err(403, 'not_a_workspace_member') + + const account = await composio.connectedAccounts.get(request_id) + + if (account.status === 'INITIATED') { + return json({ status: 'pending' }) + } + if (account.status !== 'ACTIVE') { + return json({ status: 'failed' }) + } + + // Persist with the user's RLS context so the insert passes + // app_connections_insert_member. + const { error } = await member.db + .from('app_connections') + .upsert( + { + workspace_id, + app_slug, + status: 'connected', + connected_by: user.id, + connected_at: new Date().toISOString(), + config_json: { + kind: 'composio', + connected_account_id: account.id, + account_label: account.data?.account_label ?? account.data?.user_email ?? null, + connected_at_provider: account.createdAt, + }, + }, + { onConflict: 'workspace_id,app_slug' }, + ) + if (error) return err(500, error.message) + + return json({ + status: 'connected', + account_label: account.data?.account_label ?? null, + }) +} +``` + +### `functions/apps-disconnect/index.ts` + +```typescript +// POST /apps-disconnect +// Body: { app_slug: string, workspace_id: string } +// Returns: { ok: true } + +import { ComposioClient } from '@composio/core' +import { getRequestingUser, getWorkspaceMembership, json, err } from './_shared.ts' + +const composio = new ComposioClient({ apiKey: Deno.env.get('COMPOSIO_API_KEY')! }) + +export default async function handler(req: Request): Promise { + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const user = await getRequestingUser(req) + if (!user) return err(401, 'unauthorized') + + const { app_slug, workspace_id } = await req.json() + if (!app_slug || !workspace_id) return err(400, 'missing_fields') + + const member = await getWorkspaceMembership(user.id, workspace_id) + if (!member) return err(403, 'not_a_workspace_member') + + // Look up the existing connection to get the Composio account id. + const { data: existing } = await member.db + .from('app_connections') + .select('config_json') + .eq('workspace_id', workspace_id) + .eq('app_slug', app_slug) + .single() + + const composioId = existing?.config_json?.connected_account_id + if (composioId) { + await composio.connectedAccounts.delete(composioId).catch(() => { + // Best-effort: if Composio 404s the connection is already gone. + }) + } + + const { error } = await member.db + .from('app_connections') + .delete() + .eq('workspace_id', workspace_id) + .eq('app_slug', app_slug) + if (error) return err(500, error.message) + + return json({ ok: true }) +} +``` + +### `functions/_shared.ts` + +Small helpers — `getRequestingUser` (verifies the InsForge JWT from `Authorization: Bearer`), `getWorkspaceMembership` (returns `{ db: insforgeServerClient }` scoped to that user, or `null` if not a member), and JSON response shorthands. Mirror the chatbot template's `functions/_shared.ts`. + +## 6. Frontend Changes + +### `src/features/apps/use-apps.ts` — surface `integration_kind` and `account_label` + +Extend the row shape so the UI can branch: + +```typescript +export type AppWithConnection = { + slug: string + name: string + description: string + icon_url: string | null + display_order: number + integration_kind: 'composio' | 'insforge_native' + oss_dashboard_path: string | null // for insforge_native deep link + connected: boolean + account_label: string | null +} +``` + +Update the catalog select to pull `integration_kind` and `composio_toolkit_slug`, and the connection select to pull `config_json`. Compute `oss_dashboard_path` client-side from a hard map: + +```typescript +const OSS_DEEP_LINKS: Record = { + stripe: '/payments', + openrouter: '/ai', +} +``` + +(The OSS dashboard origin is read from `import.meta.env.VITE_INSFORGE_OSS_URL`.) + +### `src/features/apps/use-toggle-app.ts` — replace stub with branched flow + +Becomes a thin router that dispatches to one of three handlers: + +```typescript +export function useConnectApp(workspaceId: string | undefined) { + // Branches on app.integration_kind: + // - composio: POST /apps-connect, open popup to redirect_url, poll until ACTIVE + // - insforge_native: window.open(`${OSS_URL}${oss_dashboard_path}`, '_blank') +} + +export function useDisconnectApp(workspaceId: string | undefined) { + // POST /apps-disconnect +} +``` + +Composio connect flow on the client: + +```typescript +async function connectComposio(app: AppWithConnection, workspaceId: string) { + const { redirect_url, request_id } = await callFunction('apps-connect', { + app_slug: app.slug, + workspace_id: workspaceId, + }) + + const popup = window.open(redirect_url, 'composio-oauth', 'width=600,height=720') + if (!popup) throw new Error('Popup blocked') + + // Poll every 1.5s for up to 2 minutes. + const deadline = Date.now() + 120_000 + while (Date.now() < deadline) { + if (popup.closed) { + // user closed without finishing — do a final poll to be sure + } + await sleep(1500) + const r = await callFunction('apps-poll', { + request_id, app_slug: app.slug, workspace_id: workspaceId, + }, { method: 'GET' }) + if (r.status === 'connected') { + popup.close() + return r + } + if (r.status === 'failed') { + popup.close() + throw new Error('Authorization failed') + } + } + popup.close() + throw new Error('Authorization timed out') +} +``` + +### `src/features/apps/apps-grid.tsx` — replace Switch with kind-aware control + +The current `` is the wrong affordance for OAuth-backed connections (you cannot meaningfully "toggle" an OAuth grant atomically). Replace with: + +| Card state | Composio app | InsForge-native app | +|------------|--------------|---------------------| +| not connected | `` → opens popup | `` → opens OSS link in new tab | +| pending | `` with spinner | n/a | +| connected | `Connected as {account_label}` + `` | `Configured` + the same dashboard link | + +Drop the `Switch` import. + +## 7. One-Time Composio Dashboard Setup + +For each of the 7 toolkits, the template author needs to do this **once** in the Composio dashboard (https://app.composio.dev) and capture the resulting `auth_config_id`: + +1. Go to **Toolkits** → pick e.g. **Slack**. +2. Click **Create Auth Config**. +3. Choose **OAuth 2.0** (Composio handles the OAuth client registration with the provider in dev mode; for production each toolkit needs its own provider-side OAuth app, configured per the Composio docs page for that toolkit). +4. Copy the `auth_config_id` (looks like `ac_AbC123…`). + +The resulting 7 IDs go into the project's secrets: + +```bash +npx @insforge/cli secrets set COMPOSIO_API_KEY '' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_GITHUB 'ac_...' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_NOTION 'ac_...' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_SLACK 'ac_...' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_DISCORD 'ac_...' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_FIGMA 'ac_...' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_LINEAR 'ac_...' +npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_VERCEL 'ac_...' +``` + +The README's "Wire a real integration" section will be rewritten as a checklist pointing at this setup. A `npm run setup:composio` script can echo the above with the project's actual secret names for convenience. + +## 8. Environment Variables + +| Var | Where | Purpose | +|-----|-------|---------| +| `COMPOSIO_API_KEY` | InsForge secrets (edge fns) | Composio SDK auth | +| `COMPOSIO_AUTH_CONFIG_` × 7 | InsForge secrets (edge fns) | per-toolkit auth config id | +| `VITE_INSFORGE_OSS_URL` | `.env.local` (SPA) | OSS dashboard origin for native deep links; defaults to `https://app.insforge.dev` | + +## 9. Open Questions / Gotchas + +1. **Workspace-as-Composio-user.** We pass `userId: workspace_id` to Composio. This means the Composio dashboard's "user" axis = our workspace axis, which is what we want (workspace-scoped connections, not per-end-user). If a future iteration wants per-end-user connections, switch to `userId: user.id`. + +2. **Composio rate limits on poll.** Composio's free tier limits API calls. The 1.5 s polling interval = ~80 calls per 2-minute OAuth window per pending connection — fine for one at a time, but if we ever support bulk-connect, switch to webhooks (Composio supports `connection.activated` webhooks). + +3. **Popup blockers.** Modern browsers block popups not opened in a direct user-gesture handler. Confirm the popup opens synchronously inside the button's `onClick`, before the `await callFunction('apps-connect', ...)` resolves — otherwise it gets blocked. The actual `redirect_url` is then assigned to `popup.location` once the fetch returns. + +4. **Stripe/OpenRouter deep links assume the user is in cloud, not OSS-only.** A user running this template against a self-hosted InsForge OSS instance will have a different dashboard URL. The `VITE_INSFORGE_OSS_URL` env var covers this; the README needs to call it out. + +5. **No Composio CLI dependency at runtime.** The skill `composio-cli` is for human operators; the template uses the Composio JavaScript SDK (`@composio/core`) directly inside edge functions. + +## 10. Demo Path (verification at the end) + +1. `cd admin-dashboard && npm install && npm run setup` +2. Set secrets per §7. +3. `npx @insforge/cli functions deploy` +4. `npm run dev`, sign in, create a workspace. +5. Go to `/apps`, click **Connect** on the Slack card. +6. Popup opens to Slack OAuth → authorize → popup closes. +7. Card now shows **Connected as `@alice in acme-eng`** with a Disconnect button. +8. Refresh — state persists. +9. Click **Disconnect** — card returns to **Connect** state, and the connection is gone from the Composio dashboard. + +If all 9 steps pass for Slack, repeat for one more (e.g. GitHub) to confirm the toolkit-agnostic code path. diff --git a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql new file mode 100644 index 0000000..ee9972b --- /dev/null +++ b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql @@ -0,0 +1,27 @@ +-- Composio Apps integration: catalog metadata + re-seed (no Zapier). +-- See docs/superpowers/specs/2026-05-26-composio-apps-integration.md + +alter table public.apps_catalog + add column if not exists integration_kind text not null default 'composio' + check (integration_kind in ('composio', 'insforge_native')); + +alter table public.apps_catalog + add column if not exists composio_toolkit_slug text; + +insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) +on conflict (slug) do update set + integration_kind = excluded.integration_kind, + composio_toolkit_slug = excluded.composio_toolkit_slug, + description = excluded.description, + display_order = excluded.display_order; + +delete from public.apps_catalog where slug = 'zapier'; diff --git a/admin-dashboard/migrations/db_init.sql b/admin-dashboard/migrations/db_init.sql index d02bc3e..a9e2059 100644 --- a/admin-dashboard/migrations/db_init.sql +++ b/admin-dashboard/migrations/db_init.sql @@ -84,6 +84,9 @@ create table if not exists public.apps_catalog ( description text not null, icon_url text, oauth_provider text, + integration_kind text not null default 'composio' + check (integration_kind in ('composio', 'insforge_native')), + composio_toolkit_slug text, display_order integer not null default 0 ); @@ -607,15 +610,14 @@ create trigger messages_realtime -- SEED — apps_catalog -- ============================================================================ -insert into public.apps_catalog (slug, name, description, icon_url, oauth_provider, display_order) values - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'stripe', 1), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', null, 2), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'github', 3), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'notion', 4), - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'slack', 5), - ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'discord', 6), - ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'figma', 7), - ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', null, 8), - ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', null, 9), - ('zapier', 'Zapier', 'Automate workflows across thousands of apps.', 'https://cdn.simpleicons.org/zapier', null, 10) +insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) on conflict (slug) do nothing; From 2420de13d62d7abb6cddcb7ca9efabc008bffb92 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:26:31 -0700 Subject: [PATCH 02/15] admin-dashboard: apps-connect edge function for composio oauth initiate --- admin-dashboard/functions/apps-connect.ts | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 admin-dashboard/functions/apps-connect.ts diff --git a/admin-dashboard/functions/apps-connect.ts b/admin-dashboard/functions/apps-connect.ts new file mode 100644 index 0000000..e0b37c3 --- /dev/null +++ b/admin-dashboard/functions/apps-connect.ts @@ -0,0 +1,92 @@ +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + let body: { app_slug?: string; workspace_id?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { app_slug, workspace_id } = body + if (!app_slug || !workspace_id) return err(400, 'missing_fields') + + const { data: appRow, error: appErr } = await client.database + .from('apps_catalog') + .select('integration_kind, composio_toolkit_slug') + .eq('slug', app_slug) + .single() + if (appErr || !appRow) return err(404, 'app_not_found', appErr?.message) + if (appRow.integration_kind !== 'composio' || !appRow.composio_toolkit_slug) { + return err(400, 'not_a_composio_app') + } + + const authConfigId = Deno.env.get( + `COMPOSIO_AUTH_CONFIG_${appRow.composio_toolkit_slug.toUpperCase()}`, + ) + if (!authConfigId) return err(500, 'auth_config_not_provisioned') + + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (!composioApiKey) return err(500, 'composio_api_key_missing') + + const initRes = await fetch( + 'https://backend.composio.dev/api/v3/connected_accounts/initiate', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': composioApiKey, + }, + body: JSON.stringify({ + auth_config_id: authConfigId, + user_id: workspace_id, + }), + }, + ) + + if (!initRes.ok) { + const text = await initRes.text() + return err(502, 'composio_initiate_failed', text) + } + const init = (await initRes.json()) as { + id: string + redirect_url?: string + redirectUrl?: string + } + + return json(200, { + request_id: init.id, + redirect_url: init.redirect_url ?? init.redirectUrl, + }) +} From 103fa925080ea78dbfade911cbaec9a900cfae9e Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:40:51 -0700 Subject: [PATCH 03/15] admin-dashboard: apps-poll edge function for composio status + persist --- admin-dashboard/functions/apps-poll.ts | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 admin-dashboard/functions/apps-poll.ts diff --git a/admin-dashboard/functions/apps-poll.ts b/admin-dashboard/functions/apps-poll.ts new file mode 100644 index 0000000..b0dae42 --- /dev/null +++ b/admin-dashboard/functions/apps-poll.ts @@ -0,0 +1,94 @@ +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'GET') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + const url = new URL(req.url) + const request_id = url.searchParams.get('request_id') + const app_slug = url.searchParams.get('app_slug') + const workspace_id = url.searchParams.get('workspace_id') + if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') + + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (!composioApiKey) return err(500, 'composio_api_key_missing') + + const res = await fetch( + `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(request_id)}`, + { headers: { 'x-api-key': composioApiKey } }, + ) + if (!res.ok) return err(502, 'composio_get_failed', await res.text()) + + const account = (await res.json()) as { + id: string + status: string + data?: Record + created_at?: string + } + + if (account.status === 'INITIATED') return json(200, { status: 'pending' }) + if (account.status !== 'ACTIVE') { + return json(200, { status: 'failed', detail: account.status }) + } + + const dataObj = (account.data ?? {}) as Record + const account_label = + (dataObj.account_label as string | undefined) ?? + (dataObj.user_email as string | undefined) ?? + (dataObj.email as string | undefined) ?? + (dataObj.login as string | undefined) ?? + (dataObj.name as string | undefined) ?? + null + + const { error: upsertErr } = await client.database + .from('app_connections') + .upsert( + { + workspace_id, + app_slug, + status: 'connected', + connected_by: userData.user.id, + connected_at: new Date().toISOString(), + config_json: { + kind: 'composio', + connected_account_id: account.id, + account_label, + connected_at_provider: account.created_at ?? null, + }, + }, + { onConflict: 'workspace_id,app_slug' }, + ) + + if (upsertErr) return err(500, 'db_upsert_failed', upsertErr.message) + + return json(200, { status: 'connected', account_label }) +} From 05d471095cb5ab25bb36d08887a553e9ae452240 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:43:01 -0700 Subject: [PATCH 04/15] admin-dashboard: apps-disconnect edge function for composio revoke --- admin-dashboard/functions/apps-disconnect.ts | 73 ++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 admin-dashboard/functions/apps-disconnect.ts diff --git a/admin-dashboard/functions/apps-disconnect.ts b/admin-dashboard/functions/apps-disconnect.ts new file mode 100644 index 0000000..6fbb9c0 --- /dev/null +++ b/admin-dashboard/functions/apps-disconnect.ts @@ -0,0 +1,73 @@ +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + let body: { app_slug?: string; workspace_id?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { app_slug, workspace_id } = body + if (!app_slug || !workspace_id) return err(400, 'missing_fields') + + const { data: existing } = await client.database + .from('app_connections') + .select('config_json') + .eq('workspace_id', workspace_id) + .eq('app_slug', app_slug) + .maybeSingle() + + const composioId = (existing?.config_json as Record | null | undefined) + ?.connected_account_id as string | undefined + + if (composioId) { + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (composioApiKey) { + await fetch( + `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(composioId)}`, + { method: 'DELETE', headers: { 'x-api-key': composioApiKey } }, + ).catch(() => {}) + } + } + + const { error: delErr } = await client.database + .from('app_connections') + .delete() + .eq('workspace_id', workspace_id) + .eq('app_slug', app_slug) + if (delErr) return err(500, 'db_delete_failed', delErr.message) + + return json(200, { ok: true }) +} From 1bd3f67a3efdad37730869513409dd76abecf96d Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:46:54 -0700 Subject: [PATCH 05/15] admin-dashboard: replace apps toggle stub with composio + native split --- .../src/features/apps/apps-grid.tsx | 158 +++++++++++++----- .../src/features/apps/apps-page.tsx | 37 ++-- admin-dashboard/src/features/apps/use-apps.ts | 54 ++++-- .../src/features/apps/use-connect-app.ts | 93 +++++++++++ .../src/features/apps/use-disconnect-app.ts | 28 ++++ .../src/features/apps/use-toggle-app.ts | 43 ----- 6 files changed, 290 insertions(+), 123 deletions(-) create mode 100644 admin-dashboard/src/features/apps/use-connect-app.ts create mode 100644 admin-dashboard/src/features/apps/use-disconnect-app.ts delete mode 100644 admin-dashboard/src/features/apps/use-toggle-app.ts diff --git a/admin-dashboard/src/features/apps/apps-grid.tsx b/admin-dashboard/src/features/apps/apps-grid.tsx index 6dac1d0..29ce78e 100644 --- a/admin-dashboard/src/features/apps/apps-grid.tsx +++ b/admin-dashboard/src/features/apps/apps-grid.tsx @@ -1,17 +1,33 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Switch } from '@/components/ui/switch' +import { ExternalLink, Loader2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import type { AppWithConnection } from './use-apps' type Props = { apps: AppWithConnection[] isLoading: boolean - isPending: (slug: string) => boolean - onToggle: (slug: string, next: boolean) => void + isConnectPending: (slug: string) => boolean + isDisconnectPending: (slug: string) => boolean + onConnect: (app: AppWithConnection) => void + onDisconnect: (app: AppWithConnection) => void } -export function AppsGrid({ apps, isLoading, isPending, onToggle }: Props) { +export function AppsGrid({ + apps, + isLoading, + isConnectPending, + isDisconnectPending, + onConnect, + onDisconnect, +}: Props) { if (isLoading) { return (
@@ -20,7 +36,7 @@ export function AppsGrid({ apps, isLoading, isPending, onToggle }: Props) {
- +
@@ -37,44 +53,102 @@ export function AppsGrid({ apps, isLoading, isPending, onToggle }: Props) { return (
- {apps.map((app) => ( - - {app.connected && ( - - Connected - - )} - -
- {app.icon_url ? ( - {`${app.name} + {apps.map((app) => { + const connectPending = isConnectPending(app.slug) + const disconnectPending = isDisconnectPending(app.slug) + const isNative = app.integration_kind === 'insforge_native' + + return ( + + {app.connected && ( + + Connected + + )} + +
+ {app.icon_url ? ( + {`${app.name} + ) : ( +
+ )} + {app.name} +
+ + {app.description} + + + + + {isNative ? ( + <> + + Managed in InsForge dashboard + + + + ) : app.connected ? ( + <> + + {app.account_label ? `as ${app.account_label}` : 'Active integration'} + + + ) : ( -
+ <> + Not connected + + )} - {app.name} -
- - {app.description} - - - - - {app.connected ? 'Active integration' : 'Not connected'} - - onToggle(app.slug, next)} - aria-label={`Toggle ${app.name}`} - /> - - - ))} +
+ + ) + })}
) } diff --git a/admin-dashboard/src/features/apps/apps-page.tsx b/admin-dashboard/src/features/apps/apps-page.tsx index bc01f42..81ec9d0 100644 --- a/admin-dashboard/src/features/apps/apps-page.tsx +++ b/admin-dashboard/src/features/apps/apps-page.tsx @@ -1,34 +1,19 @@ -import { useState } from 'react' import { useActiveWorkspace } from '@/features/dashboard/use-active-workspace' import { useApps } from './use-apps' -import { useToggleApp } from './use-toggle-app' +import { useConnectApp } from './use-connect-app' +import { useDisconnectApp } from './use-disconnect-app' import { AppsGrid } from './apps-grid' export function AppsPage() { const { workspace } = useActiveWorkspace() const { data: apps = [], isLoading } = useApps(workspace?.id) - const toggle = useToggleApp(workspace?.id) - const [pendingSlugs, setPendingSlugs] = useState>(new Set()) + const connect = useConnectApp(workspace?.id) + const disconnect = useDisconnectApp(workspace?.id) - const handleToggle = (slug: string, next: boolean) => { - setPendingSlugs((prev) => { - const s = new Set(prev) - s.add(slug) - return s - }) - toggle.mutate( - { slug, connected: next }, - { - onSettled: () => { - setPendingSlugs((prev) => { - const s = new Set(prev) - s.delete(slug) - return s - }) - }, - }, - ) - } + const isConnectPending = (slug: string) => + connect.isPending && connect.variables?.app.slug === slug + const isDisconnectPending = (slug: string) => + disconnect.isPending && disconnect.variables?.app.slug === slug return (
@@ -41,8 +26,10 @@ export function AppsPage() { pendingSlugs.has(slug)} - onToggle={handleToggle} + isConnectPending={isConnectPending} + isDisconnectPending={isDisconnectPending} + onConnect={(app) => connect.mutate({ app })} + onDisconnect={(app) => disconnect.mutate({ app })} />
) diff --git a/admin-dashboard/src/features/apps/use-apps.ts b/admin-dashboard/src/features/apps/use-apps.ts index 3e10dae..eec6a78 100644 --- a/admin-dashboard/src/features/apps/use-apps.ts +++ b/admin-dashboard/src/features/apps/use-apps.ts @@ -1,15 +1,24 @@ import { useQuery } from '@tanstack/react-query' import { insforge } from '@/lib/insforge' +export type IntegrationKind = 'composio' | 'insforge_native' + export type App = { slug: string name: string description: string icon_url: string | null - oauth_provider: string | null + integration_kind: IntegrationKind + composio_toolkit_slug: string | null display_order: number } +type ConnectionConfig = { + kind?: 'composio' + connected_account_id?: string + account_label?: string | null +} + export type AppConnection = { id: string workspace_id: string @@ -17,6 +26,12 @@ export type AppConnection = { status: 'connected' | 'disconnected' connected_at: string connected_by: string + config_json: ConnectionConfig | null +} + +const OSS_DEEP_LINKS: Record = { + stripe: '/payments', + openrouter: '/ai', } export type AppWithConnection = { @@ -25,7 +40,11 @@ export type AppWithConnection = { description: string icon_url: string | null display_order: number + integration_kind: IntegrationKind + composio_toolkit_slug: string | null + oss_dashboard_path: string | null connected: boolean + account_label: string | null } export const appsKey = (workspaceId: string | undefined) => ['apps', workspaceId] as const @@ -38,11 +57,13 @@ export function useApps(workspaceId: string | undefined) { const [catalogRes, connectionsRes] = await Promise.all([ insforge.database .from('apps_catalog') - .select('slug, name, description, icon_url, oauth_provider, display_order') + .select( + 'slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order', + ) .order('display_order', { ascending: true }), insforge.database .from('app_connections') - .select('id, workspace_id, app_slug, status, connected_at, connected_by') + .select('id, workspace_id, app_slug, status, connected_at, connected_by, config_json') .eq('workspace_id', workspaceId!), ]) @@ -51,18 +72,25 @@ export function useApps(workspaceId: string | undefined) { const catalog = (catalogRes.data ?? []) as App[] const connections = (connectionsRes.data ?? []) as AppConnection[] - const connectedSet = new Set( - connections.filter((c) => c.status === 'connected').map((c) => c.app_slug), + const bySlug = new Map( + connections.filter((c) => c.status === 'connected').map((c) => [c.app_slug, c]), ) - return catalog.map((app) => ({ - slug: app.slug, - name: app.name, - description: app.description, - icon_url: app.icon_url, - display_order: app.display_order, - connected: connectedSet.has(app.slug), - })) + return catalog.map((app) => { + const conn = bySlug.get(app.slug) + return { + slug: app.slug, + name: app.name, + description: app.description, + icon_url: app.icon_url, + display_order: app.display_order, + integration_kind: app.integration_kind, + composio_toolkit_slug: app.composio_toolkit_slug, + oss_dashboard_path: OSS_DEEP_LINKS[app.slug] ?? null, + connected: !!conn, + account_label: conn?.config_json?.account_label ?? null, + } + }) }, }) } diff --git a/admin-dashboard/src/features/apps/use-connect-app.ts b/admin-dashboard/src/features/apps/use-connect-app.ts new file mode 100644 index 0000000..9d8e67f --- /dev/null +++ b/admin-dashboard/src/features/apps/use-connect-app.ts @@ -0,0 +1,93 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { insforge } from '@/lib/insforge' +import { appsKey, type AppWithConnection } from './use-apps' + +const POLL_INTERVAL_MS = 1500 +const POLL_DEADLINE_MS = 120_000 +const OSS_BASE_URL = import.meta.env.VITE_INSFORGE_OSS_URL ?? 'https://app.insforge.dev' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +type ConnectArgs = { app: AppWithConnection } + +async function connectComposio(args: { + appSlug: string + workspaceId: string +}): Promise<{ account_label: string | null }> { + const popup = window.open('about:blank', 'composio-oauth', 'width=600,height=720') + if (!popup) throw new Error('Popup blocked — allow popups for this site and try again.') + + try { + const { data: initData, error: initErr } = await insforge.functions.invoke('apps-connect', { + body: { app_slug: args.appSlug, workspace_id: args.workspaceId }, + }) + if (initErr) throw new Error(initErr.message ?? 'Failed to start authorization') + + const { redirect_url, request_id } = initData as { + redirect_url: string + request_id: string + } + if (!redirect_url || !request_id) throw new Error('Composio did not return a redirect URL') + + popup.location.href = redirect_url + + const deadline = Date.now() + POLL_DEADLINE_MS + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS) + + const { data: pollData, error: pollErr } = await insforge.functions.invoke('apps-poll', { + method: 'GET', + body: { + request_id, + app_slug: args.appSlug, + workspace_id: args.workspaceId, + }, + }) + if (pollErr) throw new Error(pollErr.message ?? 'Polling failed') + + const result = pollData as { + status: 'pending' | 'connected' | 'failed' + account_label?: string | null + detail?: string + } + if (result.status === 'connected') { + return { account_label: result.account_label ?? null } + } + if (result.status === 'failed') { + throw new Error(`Authorization failed (${result.detail ?? 'unknown'})`) + } + if (popup.closed && result.status === 'pending') { + throw new Error('Authorization window was closed before completion') + } + } + throw new Error('Authorization timed out after 2 minutes') + } finally { + if (!popup.closed) popup.close() + } +} + +export function useConnectApp(workspaceId: string | undefined) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ app }: ConnectArgs): Promise => { + if (!workspaceId) throw new Error('No active workspace') + + if (app.integration_kind === 'insforge_native') { + if (!app.oss_dashboard_path) throw new Error('No dashboard path for this app') + window.open(`${OSS_BASE_URL}${app.oss_dashboard_path}`, '_blank', 'noopener,noreferrer') + return + } + + await connectComposio({ appSlug: app.slug, workspaceId }) + }, + onSuccess: (_data, { app }) => { + if (app.integration_kind === 'composio') { + toast.success(`${app.name} connected`) + void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) + } + }, + onError: (err: Error) => toast.error(err.message), + }) +} diff --git a/admin-dashboard/src/features/apps/use-disconnect-app.ts b/admin-dashboard/src/features/apps/use-disconnect-app.ts new file mode 100644 index 0000000..21461fd --- /dev/null +++ b/admin-dashboard/src/features/apps/use-disconnect-app.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { insforge } from '@/lib/insforge' +import { appsKey, type AppWithConnection } from './use-apps' + +type DisconnectArgs = { app: AppWithConnection } + +export function useDisconnectApp(workspaceId: string | undefined) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ app }: DisconnectArgs): Promise => { + if (!workspaceId) throw new Error('No active workspace') + if (app.integration_kind !== 'composio') { + throw new Error('Native integrations are managed in the InsForge dashboard') + } + const { error } = await insforge.functions.invoke('apps-disconnect', { + body: { app_slug: app.slug, workspace_id: workspaceId }, + }) + if (error) throw new Error(error.message ?? 'Disconnect failed') + }, + onSuccess: (_data, { app }) => { + toast.success(`${app.name} disconnected`) + void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) + }, + onError: (err: Error) => toast.error(err.message), + }) +} diff --git a/admin-dashboard/src/features/apps/use-toggle-app.ts b/admin-dashboard/src/features/apps/use-toggle-app.ts deleted file mode 100644 index 7cf500f..0000000 --- a/admin-dashboard/src/features/apps/use-toggle-app.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' -import { insforge } from '@/lib/insforge' -import { useAuth } from '@/lib/auth-context' -import { appsKey } from './use-apps' - -type ToggleArgs = { - slug: string - connected: boolean -} - -export function useToggleApp(workspaceId: string | undefined) { - const qc = useQueryClient() - const { user } = useAuth() - - return useMutation({ - mutationFn: async ({ slug, connected }: ToggleArgs): Promise => { - if (!workspaceId) throw new Error('No active workspace') - if (!user) throw new Error('Not signed in') - - const { error } = await insforge.database - .from('app_connections') - .upsert( - { - workspace_id: workspaceId, - app_slug: slug, - status: connected ? 'connected' : 'disconnected', - connected_by: user.id, - connected_at: new Date().toISOString(), - }, - { onConflict: 'workspace_id,app_slug' }, - ) - if (error) throw new Error(error.message) - }, - onSuccess: (_data, vars) => { - toast.success(vars.connected ? 'App connected' : 'App disconnected') - void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) - }, - onError: (err: Error) => { - toast.error(err.message || 'Failed to update connection') - }, - }) -} From 1908aaa0da1a4955ef0ad583de825139c67f8585 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:47:43 -0700 Subject: [PATCH 06/15] admin-dashboard: document composio apps setup --- admin-dashboard/.env.example | 4 ++++ admin-dashboard/README.md | 40 +++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/admin-dashboard/.env.example b/admin-dashboard/.env.example index fee62fb..57ae489 100644 --- a/admin-dashboard/.env.example +++ b/admin-dashboard/.env.example @@ -1,2 +1,6 @@ VITE_INSFORGE_URL=https://your-appkey.region.insforge.app VITE_INSFORGE_ANON_KEY=your-anon-key + +# OSS dashboard origin for native-integration deep links (Stripe / OpenRouter). +# Defaults to https://app.insforge.dev when unset. +VITE_INSFORGE_OSS_URL=https://app.insforge.dev diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md index 36df308..9fb3fe8 100644 --- a/admin-dashboard/README.md +++ b/admin-dashboard/README.md @@ -142,11 +142,49 @@ admin-dashboard/ --- +## Connecting third-party apps + +The `/apps` page lists every integration available to a workspace. Two kinds: + +- **InsForge-native** (Stripe, OpenRouter) — configured in the InsForge dashboard. The card opens the corresponding dashboard page in a new tab. +- **Composio-backed** (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) — connected per workspace via Composio's hosted OAuth. + +### One-time Composio setup + +1. Sign up at [composio.dev](https://composio.dev) and create an API key in **Settings → API Keys**. +2. For each of the 7 toolkits (`github`, `notion`, `slack`, `discord`, `figma`, `linear`, `vercel`), open the **Toolkits** page, click the toolkit, then **Create Auth Config** with OAuth 2.0. Copy the resulting `auth_config_id` (`ac_…`). +3. Store every value in InsForge secrets: + + ```bash + npx @insforge/cli secrets add COMPOSIO_API_KEY + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_GITHUB ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_NOTION ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_SLACK ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_DISCORD ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_FIGMA ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_LINEAR ac_xxx + npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_VERCEL ac_xxx + ``` + +4. Deploy the three edge functions (already shipped under `functions/`): + + ```bash + npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts + npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts + npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts + ``` + +Composio routes its OAuth callback to its own domain — you do **not** need to add any URLs to `insforge.toml`. You do need to allow popups in your browser; the connect flow opens one synchronously when the user clicks **Connect**. + +Connections are scoped per workspace: the same workspace user_id is passed to Composio so any member of that workspace can disconnect or replace the connection. + +--- + ## Customization - **Rebrand** — swap the app name, colors, and icon set. Tailwind CSS variables in `src/styles/globals.css` drive the entire palette; chart colors live there too. - **Add a page** — drop a new file under `src/routes/_authenticated/` and add a sidebar entry in `src/components/layout/sidebar-nav.ts`. TanStack Router picks it up on next build. -- **Wire a real integration in Apps** — replace the toggle handler in `src/features/apps/use-toggle-app.ts` with an OAuth flow. The mock pattern keeps `app_connections.status` for free. +- **Add or change Apps integrations** — see [Connecting third-party apps](#connecting-third-party-apps) below for the Composio + InsForge-native split. Add a new card by inserting a row into `apps_catalog` (set `integration_kind` and either `composio_toolkit_slug` for Composio toolkits or extend the `OSS_DEEP_LINKS` map in `src/features/apps/use-apps.ts` for native deep links). - **Email invitations** — V1 produces a copyable link. To email instead, wire `insforge.emails.send()` in `src/features/users/use-invitations.ts`. --- From 9de08345ff6e6dcc2cf29060c2dad304e579fc38 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 26 May 2026 16:51:59 -0700 Subject: [PATCH 07/15] admin-dashboard: restore Apps sidebar entry now that integrations are wired --- admin-dashboard/src/components/layout/sidebar-nav.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/admin-dashboard/src/components/layout/sidebar-nav.ts b/admin-dashboard/src/components/layout/sidebar-nav.ts index 066026e..2bdc41f 100644 --- a/admin-dashboard/src/components/layout/sidebar-nav.ts +++ b/admin-dashboard/src/components/layout/sidebar-nav.ts @@ -2,6 +2,7 @@ import { LayoutDashboard, ListTodo, Users, + Boxes, MessageSquare, Settings, LifeBuoy, @@ -18,6 +19,7 @@ export const NAV_ITEMS: NavItem[] = [ { title: 'Dashboard', to: '/dashboard', icon: LayoutDashboard }, { title: 'Tasks', to: '/tasks', icon: ListTodo }, { title: 'Users', to: '/users', icon: Users }, + { title: 'Apps', to: '/apps', icon: Boxes }, { title: 'Chats', to: '/chats', icon: MessageSquare }, { title: 'Settings', to: '/settings', icon: Settings }, { title: 'Help', to: '/help-center', icon: LifeBuoy }, From 38816cdb7c540908cd548dc0f353bf787db67478 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 12:49:59 -0700 Subject: [PATCH 08/15] admin-dashboard: graceful fallback when composio is not configured --- admin-dashboard/README.md | 8 ++- admin-dashboard/functions/apps-config.ts | 51 +++++++++++++++++++ ...260526150000_composio-apps-integration.sql | 10 ++-- admin-dashboard/migrations/db_init.sql | 10 ++-- .../src/features/apps/apps-grid.tsx | 11 ++++ .../src/features/apps/apps-page.tsx | 48 ++++++++++++++++- .../src/features/apps/use-apps-config.ts | 31 +++++++++++ admin-dashboard/src/features/apps/use-apps.ts | 2 + 8 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 admin-dashboard/functions/apps-config.ts create mode 100644 admin-dashboard/src/features/apps/use-apps-config.ts diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md index 9fb3fe8..abf146f 100644 --- a/admin-dashboard/README.md +++ b/admin-dashboard/README.md @@ -27,7 +27,7 @@ A polished, end-to-end admin dashboard starter built with Vite, TanStack Router/ Demo: [admindashboard.insforge.site](https://admindashboard.insforge.site) -Sign up or sign in to get a personal workspace, then explore. Every page is hooked to live InsForge data — there is no mock layer. +Sign up or sign in to get a personal workspace, then explore. Every page is hooked to live InsForge data. The Apps page renders out of the box with Stripe and OpenRouter wired to the InsForge dashboard; the seven Composio-backed integrations (GitHub, Slack, Notion, Discord, Figma, Linear, Vercel) appear as "Setup required" until you provision Composio (see [Connecting third-party apps](#connecting-third-party-apps)). --- @@ -149,6 +149,12 @@ The `/apps` page lists every integration available to a workspace. Two kinds: - **InsForge-native** (Stripe, OpenRouter) — configured in the InsForge dashboard. The card opens the corresponding dashboard page in a new tab. - **Composio-backed** (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) — connected per workspace via Composio's hosted OAuth. +### Default behavior without Composio + +Out of the box, the `/apps` page works with zero third-party setup: Stripe and OpenRouter cards deep-link to the InsForge dashboard, and the seven Composio cards render with a disabled **Connect** button and a "Setup required" label. A banner at the top points to this section. Follow the steps below to switch any of the seven on. + +The detection is per-toolkit: only the ones whose `COMPOSIO_AUTH_CONFIG_*` secret you provision become connectable. The rest stay disabled. + ### One-time Composio setup 1. Sign up at [composio.dev](https://composio.dev) and create an API key in **Settings → API Keys**. diff --git a/admin-dashboard/functions/apps-config.ts b/admin-dashboard/functions/apps-config.ts new file mode 100644 index 0000000..e60151f --- /dev/null +++ b/admin-dashboard/functions/apps-config.ts @@ -0,0 +1,51 @@ +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +const COMPOSIO_TOOLKITS = [ + 'github', + 'notion', + 'slack', + 'discord', + 'figma', + 'linear', + 'vercel', +] as const + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'GET') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + const composio_enabled = !!Deno.env.get('COMPOSIO_API_KEY') + const configured_toolkits = COMPOSIO_TOOLKITS.filter( + (slug) => !!Deno.env.get(`COMPOSIO_AUTH_CONFIG_${slug.toUpperCase()}`), + ) + + return json(200, { composio_enabled, configured_toolkits }) +} diff --git a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql index ee9972b..8e21830 100644 --- a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql +++ b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql @@ -9,11 +9,11 @@ alter table public.apps_catalog add column if not exists composio_toolkit_slug text; insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/slack.svg','composio', 'slack', 1), + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 2), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 3), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 4), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 5), ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), diff --git a/admin-dashboard/migrations/db_init.sql b/admin-dashboard/migrations/db_init.sql index a9e2059..9d908b5 100644 --- a/admin-dashboard/migrations/db_init.sql +++ b/admin-dashboard/migrations/db_init.sql @@ -611,11 +611,11 @@ create trigger messages_realtime -- ============================================================================ insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/slack.svg','composio', 'slack', 1), + ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 2), + ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 3), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 4), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 5), ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), diff --git a/admin-dashboard/src/features/apps/apps-grid.tsx b/admin-dashboard/src/features/apps/apps-grid.tsx index 29ce78e..62b5105 100644 --- a/admin-dashboard/src/features/apps/apps-grid.tsx +++ b/admin-dashboard/src/features/apps/apps-grid.tsx @@ -125,6 +125,17 @@ export function AppsGrid({ )} + ) : !app.is_available ? ( + <> + Coming soon + + ) : ( <> Not connected diff --git a/admin-dashboard/src/features/apps/apps-page.tsx b/admin-dashboard/src/features/apps/apps-page.tsx index 81ec9d0..576f413 100644 --- a/admin-dashboard/src/features/apps/apps-page.tsx +++ b/admin-dashboard/src/features/apps/apps-page.tsx @@ -1,15 +1,36 @@ +import { useMemo } from 'react' +import { Info } from 'lucide-react' import { useActiveWorkspace } from '@/features/dashboard/use-active-workspace' import { useApps } from './use-apps' +import { useAppsConfig } from './use-apps-config' import { useConnectApp } from './use-connect-app' import { useDisconnectApp } from './use-disconnect-app' import { AppsGrid } from './apps-grid' +const COMPOSIO_SETUP_URL = + 'https://github.com/InsForge/insforge-templates/tree/main/admin-dashboard#connecting-third-party-apps' + export function AppsPage() { const { workspace } = useActiveWorkspace() const { data: apps = [], isLoading } = useApps(workspace?.id) + const { data: appsConfig, isLoading: isConfigLoading } = useAppsConfig() const connect = useConnectApp(workspace?.id) const disconnect = useDisconnectApp(workspace?.id) + const enrichedApps = useMemo(() => { + const toolkits = new Set(appsConfig?.configured_toolkits ?? []) + return apps.map((app) => { + if (app.integration_kind === 'insforge_native') return app + const slug = app.composio_toolkit_slug + const available = !!appsConfig?.composio_enabled && !!slug && toolkits.has(slug) + return { ...app, is_available: available } + }) + }, [apps, appsConfig]) + + const hasComposioApp = enrichedApps.some((a) => a.integration_kind === 'composio') + const showSetupBanner = + !isConfigLoading && hasComposioApp && !appsConfig?.composio_enabled + const isConnectPending = (slug: string) => connect.isPending && connect.variables?.app.slug === slug const isDisconnectPending = (slug: string) => @@ -23,9 +44,32 @@ export function AppsPage() { Connect integrations to extend {workspace?.name ?? 'your workspace'}.

+ {showSetupBanner && ( +
+ +
+

Third-party integrations need Composio

+

+ Stripe and OpenRouter work out of the box. To enable GitHub, Slack, Notion and + the other OAuth apps, provision Composio secrets on this project. +

+ + View setup guide → + +
+
+ )} connect.mutate({ app })} diff --git a/admin-dashboard/src/features/apps/use-apps-config.ts b/admin-dashboard/src/features/apps/use-apps-config.ts new file mode 100644 index 0000000..5bb63e7 --- /dev/null +++ b/admin-dashboard/src/features/apps/use-apps-config.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query' +import { insforge } from '@/lib/insforge' + +export type AppsConfig = { + composio_enabled: boolean + configured_toolkits: string[] +} + +const FALLBACK: AppsConfig = { composio_enabled: false, configured_toolkits: [] } + +export const appsConfigKey = ['apps-config'] as const + +export function useAppsConfig() { + return useQuery({ + queryKey: appsConfigKey, + queryFn: async (): Promise => { + const { data, error } = await insforge.functions.invoke('apps-config', { + method: 'GET', + }) + if (error) return FALLBACK + const cfg = data as Partial | null + return { + composio_enabled: !!cfg?.composio_enabled, + configured_toolkits: Array.isArray(cfg?.configured_toolkits) + ? cfg!.configured_toolkits + : [], + } + }, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/admin-dashboard/src/features/apps/use-apps.ts b/admin-dashboard/src/features/apps/use-apps.ts index eec6a78..1f4d139 100644 --- a/admin-dashboard/src/features/apps/use-apps.ts +++ b/admin-dashboard/src/features/apps/use-apps.ts @@ -45,6 +45,7 @@ export type AppWithConnection = { oss_dashboard_path: string | null connected: boolean account_label: string | null + is_available: boolean } export const appsKey = (workspaceId: string | undefined) => ['apps', workspaceId] as const @@ -89,6 +90,7 @@ export function useApps(workspaceId: string | undefined) { oss_dashboard_path: OSS_DEEP_LINKS[app.slug] ?? null, connected: !!conn, account_label: conn?.config_json?.account_label ?? null, + is_available: app.integration_kind === 'insforge_native', } }) }, From f32a27158e1936fba1b8973923e99287a5aad16b Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 12:54:43 -0700 Subject: [PATCH 09/15] admin-dashboard: send-to-slack action on tasks --- .../functions/apps-slack-list-channels.ts | 93 +++++++++++++ .../functions/apps-slack-send-task.ts | 123 ++++++++++++++++ .../features/apps/use-send-task-to-slack.ts | 32 +++++ .../src/features/apps/use-slack-channels.ts | 28 ++++ .../src/features/tasks/columns.tsx | 11 ++ .../features/tasks/share-to-slack-dialog.tsx | 131 ++++++++++++++++++ .../src/features/tasks/tasks-page.tsx | 24 +++- 7 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 admin-dashboard/functions/apps-slack-list-channels.ts create mode 100644 admin-dashboard/functions/apps-slack-send-task.ts create mode 100644 admin-dashboard/src/features/apps/use-send-task-to-slack.ts create mode 100644 admin-dashboard/src/features/apps/use-slack-channels.ts create mode 100644 admin-dashboard/src/features/tasks/share-to-slack-dialog.tsx diff --git a/admin-dashboard/functions/apps-slack-list-channels.ts b/admin-dashboard/functions/apps-slack-list-channels.ts new file mode 100644 index 0000000..06851e3 --- /dev/null +++ b/admin-dashboard/functions/apps-slack-list-channels.ts @@ -0,0 +1,93 @@ +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +type Channel = { id: string; name: string; is_private: boolean } + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + let body: { workspace_id?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { workspace_id } = body + if (!workspace_id) return err(400, 'missing_fields') + + const { data: connRow, error: connErr } = await client.database + .from('app_connections') + .select('config_json, status') + .eq('workspace_id', workspace_id) + .eq('app_slug', 'slack') + .single() + if (connErr || !connRow) return err(404, 'slack_not_connected') + if (connRow.status !== 'connected') return err(409, 'slack_not_connected') + + const connectedAccountId = (connRow.config_json as { connected_account_id?: string }) + ?.connected_account_id + if (!connectedAccountId) return err(409, 'slack_not_connected') + + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (!composioApiKey) return err(500, 'composio_api_key_missing') + + const execRes = await fetch( + 'https://backend.composio.dev/api/v3/tools/execute/SLACK_LIST_ALL_CHANNELS', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': composioApiKey }, + body: JSON.stringify({ + user_id: workspace_id, + connected_account_id: connectedAccountId, + arguments: { limit: 200, exclude_archived: true, types: 'public_channel,private_channel' }, + }), + }, + ) + + if (!execRes.ok) { + return err(502, 'composio_execute_failed', await execRes.text()) + } + const execBody = (await execRes.json()) as { + successful?: boolean + data?: { channels?: Array<{ id: string; name: string; is_private?: boolean }> } + error?: string + } + if (!execBody.successful) return err(502, 'composio_execute_failed', execBody.error ?? 'unknown') + + const channels: Channel[] = (execBody.data?.channels ?? []).map((c) => ({ + id: c.id, + name: c.name, + is_private: !!c.is_private, + })) + channels.sort((a, b) => a.name.localeCompare(b.name)) + + return json(200, { channels }) +} diff --git a/admin-dashboard/functions/apps-slack-send-task.ts b/admin-dashboard/functions/apps-slack-send-task.ts new file mode 100644 index 0000000..3549998 --- /dev/null +++ b/admin-dashboard/functions/apps-slack-send-task.ts @@ -0,0 +1,123 @@ +import { createClient } from 'npm:@insforge/sdk' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }) + +const err = (status: number, code: string, detail?: string) => + json(status, { error: code, detail }) + +const STATUS_LABEL: Record = { + backlog: 'Backlog', + todo: 'To do', + in_progress: 'In progress', + done: 'Done', + canceled: 'Canceled', +} + +const PRIORITY_LABEL: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', +} + +function formatMessage(task: { + title: string + description: string | null + status: string + priority: string + due_date: string | null +}) { + const lines: string[] = [] + lines.push(`*${task.title}*`) + lines.push( + `${STATUS_LABEL[task.status] ?? task.status} · ${PRIORITY_LABEL[task.priority] ?? task.priority}` + + (task.due_date ? ` · due ${task.due_date}` : ''), + ) + if (task.description) lines.push('', task.description.slice(0, 500)) + return lines.join('\n') +} + +export default async function handler(req: Request): Promise { + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }) + } + if (req.method !== 'POST') return err(405, 'method_not_allowed') + + const authHeader = req.headers.get('Authorization') + const userToken = authHeader ? authHeader.replace('Bearer ', '') : null + if (!userToken) return err(401, 'unauthorized') + + const client = createClient({ + baseUrl: Deno.env.get('INSFORGE_BASE_URL'), + edgeFunctionToken: userToken, + }) + const { data: userData } = await client.auth.getCurrentUser() + if (!userData?.user?.id) return err(401, 'unauthorized') + + let body: { task_id?: string; workspace_id?: string; channel?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { task_id, workspace_id, channel } = body + if (!task_id || !workspace_id || !channel) return err(400, 'missing_fields') + + const { data: task, error: taskErr } = await client.database + .from('tasks') + .select('id, title, description, status, priority, due_date, workspace_id') + .eq('id', task_id) + .single() + if (taskErr || !task) return err(404, 'task_not_found', taskErr?.message) + if (task.workspace_id !== workspace_id) return err(403, 'task_workspace_mismatch') + + const { data: connRow, error: connErr } = await client.database + .from('app_connections') + .select('config_json, status') + .eq('workspace_id', workspace_id) + .eq('app_slug', 'slack') + .single() + if (connErr || !connRow) return err(409, 'slack_not_connected') + if (connRow.status !== 'connected') return err(409, 'slack_not_connected') + + const connectedAccountId = (connRow.config_json as { connected_account_id?: string }) + ?.connected_account_id + if (!connectedAccountId) return err(409, 'slack_not_connected') + + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') + if (!composioApiKey) return err(500, 'composio_api_key_missing') + + const text = formatMessage(task) + + const execRes = await fetch( + 'https://backend.composio.dev/api/v3/tools/execute/SLACK_SEND_MESSAGE', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': composioApiKey }, + body: JSON.stringify({ + user_id: workspace_id, + connected_account_id: connectedAccountId, + arguments: { channel, text, mrkdwn: true }, + }), + }, + ) + + if (!execRes.ok) { + return err(502, 'composio_execute_failed', await execRes.text()) + } + const execBody = (await execRes.json()) as { successful?: boolean; error?: string } + if (!execBody.successful) { + return err(502, 'composio_execute_failed', execBody.error ?? 'unknown') + } + + return json(200, { ok: true, channel }) +} diff --git a/admin-dashboard/src/features/apps/use-send-task-to-slack.ts b/admin-dashboard/src/features/apps/use-send-task-to-slack.ts new file mode 100644 index 0000000..5b999fa --- /dev/null +++ b/admin-dashboard/src/features/apps/use-send-task-to-slack.ts @@ -0,0 +1,32 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { insforge } from '@/lib/insforge' + +type SendArgs = { + taskId: string + taskTitle: string + channelId: string + channelName: string +} + +export function useSendTaskToSlack(workspaceId: string | undefined) { + return useMutation({ + mutationFn: async (args: SendArgs) => { + if (!workspaceId) throw new Error('No active workspace') + const { error } = await insforge.functions.invoke('apps-slack-send-task', { + method: 'POST', + body: { + task_id: args.taskId, + workspace_id: workspaceId, + channel: args.channelId, + }, + }) + if (error) throw new Error(error.message ?? 'Failed to send to Slack') + return args + }, + onSuccess: (args) => { + toast.success(`Sent "${args.taskTitle}" to #${args.channelName}`) + }, + onError: (err: Error) => toast.error(err.message), + }) +} diff --git a/admin-dashboard/src/features/apps/use-slack-channels.ts b/admin-dashboard/src/features/apps/use-slack-channels.ts new file mode 100644 index 0000000..29a9d8c --- /dev/null +++ b/admin-dashboard/src/features/apps/use-slack-channels.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query' +import { insforge } from '@/lib/insforge' + +export type SlackChannel = { + id: string + name: string + is_private: boolean +} + +export const slackChannelsKey = (workspaceId: string | undefined) => + ['slack-channels', workspaceId] as const + +export function useSlackChannels(workspaceId: string | undefined, enabled: boolean) { + return useQuery({ + enabled: !!workspaceId && enabled, + queryKey: slackChannelsKey(workspaceId), + queryFn: async (): Promise => { + const { data, error } = await insforge.functions.invoke('apps-slack-list-channels', { + method: 'POST', + body: { workspace_id: workspaceId }, + }) + if (error) throw new Error(error.message ?? 'Failed to load Slack channels') + const channels = (data as { channels?: SlackChannel[] } | null)?.channels ?? [] + return channels + }, + staleTime: 60 * 1000, + }) +} diff --git a/admin-dashboard/src/features/tasks/columns.tsx b/admin-dashboard/src/features/tasks/columns.tsx index c2bbff7..bbebb04 100644 --- a/admin-dashboard/src/features/tasks/columns.tsx +++ b/admin-dashboard/src/features/tasks/columns.tsx @@ -13,6 +13,7 @@ import { Lightbulb, MoreHorizontal, Pencil, + Send, Sparkles, Trash2, XCircle, @@ -37,6 +38,8 @@ export type TaskRowActions = { onEdit: (task: Task) => void onDuplicate: (task: Task) => void onDelete: (task: Task) => void + onShareToSlack: (task: Task) => void + slackConnected: boolean } type StatusMeta = { label: string; icon: LucideIcon; className: string } @@ -290,6 +293,14 @@ export function buildColumns(actions: TaskRowActions): ColumnDef[] { Duplicate + actions.onShareToSlack(task)} + disabled={!actions.slackConnected} + > + + {actions.slackConnected ? 'Send to Slack' : 'Send to Slack (connect first)'} + + actions.onDelete(task)} className="text-destructive focus:text-destructive" diff --git a/admin-dashboard/src/features/tasks/share-to-slack-dialog.tsx b/admin-dashboard/src/features/tasks/share-to-slack-dialog.tsx new file mode 100644 index 0000000..02a6904 --- /dev/null +++ b/admin-dashboard/src/features/tasks/share-to-slack-dialog.tsx @@ -0,0 +1,131 @@ +import { useMemo, useState } from 'react' +import { Hash, Loader2, Lock, Search } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' +import { useSlackChannels, type SlackChannel } from '@/features/apps/use-slack-channels' +import { useSendTaskToSlack } from '@/features/apps/use-send-task-to-slack' +import type { Task } from './schemas' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + task: Task | null + workspaceId: string | undefined +} + +export function ShareToSlackDialog({ open, onOpenChange, task, workspaceId }: Props) { + const [query, setQuery] = useState('') + const { data: channels = [], isLoading, error } = useSlackChannels(workspaceId, open) + const sendMutation = useSendTaskToSlack(workspaceId) + + const filtered = useMemo(() => { + if (!query) return channels + const q = query.toLowerCase() + return channels.filter((c) => c.name.toLowerCase().includes(q)) + }, [channels, query]) + + const handlePick = (channel: SlackChannel) => { + if (!task) return + sendMutation.mutate( + { + taskId: task.id, + taskTitle: task.title, + channelId: channel.id, + channelName: channel.name, + }, + { + onSuccess: () => { + onOpenChange(false) + setQuery('') + }, + }, + ) + } + + return ( + { + if (sendMutation.isPending) return + if (!next) setQuery('') + onOpenChange(next) + }} + > + + + Send to Slack + + {task ? `Pick a channel to share "${task.title}".` : 'Pick a Slack channel.'} + + + +
+ + setQuery(e.target.value)} + placeholder="Search channels" + className="pl-8" + autoFocus + /> +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ ) : error ? ( +

+ {(error as Error).message} +

+ ) : filtered.length === 0 ? ( +

+ {channels.length === 0 ? 'No channels found.' : 'No matches.'} +

+ ) : ( +
    + {filtered.map((c) => { + const isSending = + sendMutation.isPending && sendMutation.variables?.channelId === c.id + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/admin-dashboard/src/features/tasks/tasks-page.tsx b/admin-dashboard/src/features/tasks/tasks-page.tsx index 8e1af43..68e00bc 100644 --- a/admin-dashboard/src/features/tasks/tasks-page.tsx +++ b/admin-dashboard/src/features/tasks/tasks-page.tsx @@ -10,9 +10,11 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { useActiveWorkspace } from '@/features/dashboard/use-active-workspace' +import { useApps } from '@/features/apps/use-apps' import { buildColumns } from './columns' import { TasksDataTable } from './data-table' import { TaskFormDialog, type TaskFormMode } from './task-form-dialog' +import { ShareToSlackDialog } from './share-to-slack-dialog' import { useBulkDeleteTasks, useCreateTask, @@ -40,6 +42,12 @@ export function TasksPage() { const [confirmOpen, setConfirmOpen] = useState(false) const [pendingDelete, setPendingDelete] = useState(null) + const [shareOpen, setShareOpen] = useState(false) + const [shareTask, setShareTask] = useState(null) + + const { data: apps = [] } = useApps(workspaceId) + const slackConnected = apps.some((a) => a.slug === 'slack' && a.connected) + const openCreate = () => { setDialogMode('create') setActiveTask(null) @@ -73,14 +81,21 @@ export function TasksPage() { setConfirmOpen(true) } + const openShareToSlack = (task: Task) => { + setShareTask(task) + setShareOpen(true) + } + const columns = useMemo( () => buildColumns({ onEdit: openEdit, onDuplicate: openDuplicate, onDelete: askDelete, + onShareToSlack: openShareToSlack, + slackConnected, }), - [], + [slackConnected], ) const handleSubmit = async (values: TaskFormValues) => { @@ -134,6 +149,13 @@ export function TasksPage() { onSubmit={handleSubmit} /> + + From 60ceed4f6c3e70dadcbf03e29973594c322f0ad7 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 12:55:59 -0700 Subject: [PATCH 10/15] admin-dashboard: align composio calls with v3 link endpoint and POST dispatch --- admin-dashboard/functions/apps-connect.ts | 21 +++++++-------- admin-dashboard/functions/apps-poll.ts | 27 +++++++++++++------ .../src/features/apps/use-connect-app.ts | 3 ++- .../src/features/apps/use-disconnect-app.ts | 1 + 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/admin-dashboard/functions/apps-connect.ts b/admin-dashboard/functions/apps-connect.ts index e0b37c3..de11879 100644 --- a/admin-dashboard/functions/apps-connect.ts +++ b/admin-dashboard/functions/apps-connect.ts @@ -60,8 +60,8 @@ export default async function handler(req: Request): Promise { const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') if (!composioApiKey) return err(500, 'composio_api_key_missing') - const initRes = await fetch( - 'https://backend.composio.dev/api/v3/connected_accounts/initiate', + const linkRes = await fetch( + 'https://backend.composio.dev/api/v3/connected_accounts/link', { method: 'POST', headers: { @@ -75,18 +75,17 @@ export default async function handler(req: Request): Promise { }, ) - if (!initRes.ok) { - const text = await initRes.text() - return err(502, 'composio_initiate_failed', text) + if (!linkRes.ok) { + const text = await linkRes.text() + return err(502, 'composio_link_failed', text) } - const init = (await initRes.json()) as { - id: string - redirect_url?: string - redirectUrl?: string + const link = (await linkRes.json()) as { + connected_account_id: string + redirect_url: string } return json(200, { - request_id: init.id, - redirect_url: init.redirect_url ?? init.redirectUrl, + request_id: link.connected_account_id, + redirect_url: link.redirect_url, }) } diff --git a/admin-dashboard/functions/apps-poll.ts b/admin-dashboard/functions/apps-poll.ts index b0dae42..71960a5 100644 --- a/admin-dashboard/functions/apps-poll.ts +++ b/admin-dashboard/functions/apps-poll.ts @@ -2,7 +2,7 @@ import { createClient } from 'npm:@insforge/sdk' const corsHeaders = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', } @@ -19,7 +19,7 @@ export default async function handler(req: Request): Promise { if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders }) } - if (req.method !== 'GET') return err(405, 'method_not_allowed') + if (req.method !== 'POST') return err(405, 'method_not_allowed') const authHeader = req.headers.get('Authorization') const userToken = authHeader ? authHeader.replace('Bearer ', '') : null @@ -33,10 +33,13 @@ export default async function handler(req: Request): Promise { const { data: userData } = await client.auth.getCurrentUser() if (!userData?.user?.id) return err(401, 'unauthorized') - const url = new URL(req.url) - const request_id = url.searchParams.get('request_id') - const app_slug = url.searchParams.get('app_slug') - const workspace_id = url.searchParams.get('workspace_id') + let body: { request_id?: string; app_slug?: string; workspace_id?: string } + try { + body = await req.json() + } catch { + return err(400, 'invalid_json') + } + const { request_id, app_slug, workspace_id } = body if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') @@ -51,22 +54,30 @@ export default async function handler(req: Request): Promise { const account = (await res.json()) as { id: string status: string + word_id?: string data?: Record created_at?: string } - if (account.status === 'INITIATED') return json(200, { status: 'pending' }) + const FAILED_STATES = ['FAILED', 'EXPIRED', 'INACTIVE', 'DELETED'] if (account.status !== 'ACTIVE') { - return json(200, { status: 'failed', detail: account.status }) + if (FAILED_STATES.includes(account.status)) { + return json(200, { status: 'failed', detail: account.status }) + } + return json(200, { status: 'pending', detail: account.status }) } const dataObj = (account.data ?? {}) as Record + const extraToken = (dataObj.extra_token_data as Record | undefined) ?? {} + const team = (extraToken.team as { name?: string } | undefined) ?? {} const account_label = (dataObj.account_label as string | undefined) ?? (dataObj.user_email as string | undefined) ?? (dataObj.email as string | undefined) ?? (dataObj.login as string | undefined) ?? (dataObj.name as string | undefined) ?? + team.name ?? + account.word_id ?? null const { error: upsertErr } = await client.database diff --git a/admin-dashboard/src/features/apps/use-connect-app.ts b/admin-dashboard/src/features/apps/use-connect-app.ts index 9d8e67f..beeab45 100644 --- a/admin-dashboard/src/features/apps/use-connect-app.ts +++ b/admin-dashboard/src/features/apps/use-connect-app.ts @@ -20,6 +20,7 @@ async function connectComposio(args: { try { const { data: initData, error: initErr } = await insforge.functions.invoke('apps-connect', { + method: 'POST', body: { app_slug: args.appSlug, workspace_id: args.workspaceId }, }) if (initErr) throw new Error(initErr.message ?? 'Failed to start authorization') @@ -37,7 +38,7 @@ async function connectComposio(args: { await sleep(POLL_INTERVAL_MS) const { data: pollData, error: pollErr } = await insforge.functions.invoke('apps-poll', { - method: 'GET', + method: 'POST', body: { request_id, app_slug: args.appSlug, diff --git a/admin-dashboard/src/features/apps/use-disconnect-app.ts b/admin-dashboard/src/features/apps/use-disconnect-app.ts index 21461fd..adaddea 100644 --- a/admin-dashboard/src/features/apps/use-disconnect-app.ts +++ b/admin-dashboard/src/features/apps/use-disconnect-app.ts @@ -15,6 +15,7 @@ export function useDisconnectApp(workspaceId: string | undefined) { throw new Error('Native integrations are managed in the InsForge dashboard') } const { error } = await insforge.functions.invoke('apps-disconnect', { + method: 'POST', body: { app_slug: app.slug, workspace_id: workspaceId }, }) if (error) throw new Error(error.message ?? 'Disconnect failed') From 0128af7c72daafba493694e2764b1ac8e112135b Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 13:53:24 -0700 Subject: [PATCH 11/15] admin-dashboard: add google icon to oauth sign-in buttons --- .../src/components/oauth-provider-icon.tsx | 49 +++++++++++++++++++ admin-dashboard/src/routes/(auth)/sign-in.tsx | 6 +-- 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 admin-dashboard/src/components/oauth-provider-icon.tsx diff --git a/admin-dashboard/src/components/oauth-provider-icon.tsx b/admin-dashboard/src/components/oauth-provider-icon.tsx new file mode 100644 index 0000000..61e97ab --- /dev/null +++ b/admin-dashboard/src/components/oauth-provider-icon.tsx @@ -0,0 +1,49 @@ +type OAuthProviderIconProps = { + provider: string + className?: string +} + +export function OAuthProviderIcon({ provider, className = 'h-4 w-4' }: OAuthProviderIconProps) { + switch (provider.toLowerCase()) { + case 'google': + return ( + + ) + case 'github': + return ( + + ) + default: + return null + } +} diff --git a/admin-dashboard/src/routes/(auth)/sign-in.tsx b/admin-dashboard/src/routes/(auth)/sign-in.tsx index 635dea1..6410fcb 100644 --- a/admin-dashboard/src/routes/(auth)/sign-in.tsx +++ b/admin-dashboard/src/routes/(auth)/sign-in.tsx @@ -8,7 +8,7 @@ import { insforge } from '@/lib/insforge' import { useAuth } from '@/lib/auth-context' import { ensureWorkspace } from '@/features/auth/ensure-workspace' import { useWorkspaceStore } from '@/features/workspaces/workspace-store' -import { Github } from 'lucide-react' +import { OAuthProviderIcon } from '@/components/oauth-provider-icon' export const Route = createFileRoute('/(auth)/sign-in')({ component: SignInPage, @@ -91,10 +91,10 @@ function SignInPage() {

From 91165f14ab2ed4e6bafb7649d7a1e2a9a330ca1a Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 13:54:08 -0700 Subject: [PATCH 12/15] admin-dashboard: trim apps catalog to composio-only and refresh registry --- admin-dashboard/.env.example | 4 - admin-dashboard/README.md | 23 ++-- ...260526150000_composio-apps-integration.sql | 28 ++--- admin-dashboard/migrations/db_init.sql | 20 ++-- .../src/features/apps/apps-grid.tsx | 20 +--- .../src/features/apps/apps-page.tsx | 8 +- admin-dashboard/src/features/apps/use-apps.ts | 16 +-- .../src/features/apps/use-connect-app.ts | 14 +-- admin-dashboard/src/routeTree.gen.ts | 21 ---- .../src/routes/(auth)/sign-in-2.tsx | 108 ------------------ registry.json | 6 +- 11 files changed, 42 insertions(+), 226 deletions(-) delete mode 100644 admin-dashboard/src/routes/(auth)/sign-in-2.tsx diff --git a/admin-dashboard/.env.example b/admin-dashboard/.env.example index 57ae489..fee62fb 100644 --- a/admin-dashboard/.env.example +++ b/admin-dashboard/.env.example @@ -1,6 +1,2 @@ VITE_INSFORGE_URL=https://your-appkey.region.insforge.app VITE_INSFORGE_ANON_KEY=your-anon-key - -# OSS dashboard origin for native-integration deep links (Stripe / OpenRouter). -# Defaults to https://app.insforge.dev when unset. -VITE_INSFORGE_OSS_URL=https://app.insforge.dev diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md index abf146f..0a4755c 100644 --- a/admin-dashboard/README.md +++ b/admin-dashboard/README.md @@ -27,7 +27,7 @@ A polished, end-to-end admin dashboard starter built with Vite, TanStack Router/ Demo: [admindashboard.insforge.site](https://admindashboard.insforge.site) -Sign up or sign in to get a personal workspace, then explore. Every page is hooked to live InsForge data. The Apps page renders out of the box with Stripe and OpenRouter wired to the InsForge dashboard; the seven Composio-backed integrations (GitHub, Slack, Notion, Discord, Figma, Linear, Vercel) appear as "Setup required" until you provision Composio (see [Connecting third-party apps](#connecting-third-party-apps)). +Sign up or sign in to get a personal workspace, then explore. Every page is hooked to live InsForge data. The Apps page lists seven Composio-backed integrations (Slack, GitHub, Notion, Discord, Figma, Linear, Vercel) — once Composio is provisioned (see [Connecting third-party apps](#connecting-third-party-apps)) you can OAuth into the chosen workspace and, from `/tasks`, share any row to a Slack channel with two clicks. --- @@ -144,14 +144,11 @@ admin-dashboard/ ## Connecting third-party apps -The `/apps` page lists every integration available to a workspace. Two kinds: - -- **InsForge-native** (Stripe, OpenRouter) — configured in the InsForge dashboard. The card opens the corresponding dashboard page in a new tab. -- **Composio-backed** (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) — connected per workspace via Composio's hosted OAuth. +The `/apps` page lists seven Composio-backed integrations (Slack, GitHub, Notion, Discord, Figma, Linear, Vercel), each connected per workspace via Composio's hosted OAuth. ### Default behavior without Composio -Out of the box, the `/apps` page works with zero third-party setup: Stripe and OpenRouter cards deep-link to the InsForge dashboard, and the seven Composio cards render with a disabled **Connect** button and a "Setup required" label. A banner at the top points to this section. Follow the steps below to switch any of the seven on. +Out of the box, the `/apps` page renders all seven cards with a disabled **Connect** button and a "Coming soon" label. A banner at the top points to this section. Follow the steps below to switch any of the seven on. The detection is per-toolkit: only the ones whose `COMPOSIO_AUTH_CONFIG_*` secret you provision become connectable. The rest stay disabled. @@ -172,12 +169,15 @@ The detection is per-toolkit: only the ones whose `COMPOSIO_AUTH_CONFIG_*` secre npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_VERCEL ac_xxx ``` -4. Deploy the three edge functions (already shipped under `functions/`): +4. Deploy the six edge functions (already shipped under `functions/`): ```bash - npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts - npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts - npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts + npx @insforge/cli functions deploy apps-config --file ./functions/apps-config.ts + npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts + npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts + npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts + npx @insforge/cli functions deploy apps-slack-list-channels --file ./functions/apps-slack-list-channels.ts + npx @insforge/cli functions deploy apps-slack-send-task --file ./functions/apps-slack-send-task.ts ``` Composio routes its OAuth callback to its own domain — you do **not** need to add any URLs to `insforge.toml`. You do need to allow popups in your browser; the connect flow opens one synchronously when the user clicks **Connect**. @@ -190,7 +190,8 @@ Connections are scoped per workspace: the same workspace user_id is passed to Co - **Rebrand** — swap the app name, colors, and icon set. Tailwind CSS variables in `src/styles/globals.css` drive the entire palette; chart colors live there too. - **Add a page** — drop a new file under `src/routes/_authenticated/` and add a sidebar entry in `src/components/layout/sidebar-nav.ts`. TanStack Router picks it up on next build. -- **Add or change Apps integrations** — see [Connecting third-party apps](#connecting-third-party-apps) below for the Composio + InsForge-native split. Add a new card by inserting a row into `apps_catalog` (set `integration_kind` and either `composio_toolkit_slug` for Composio toolkits or extend the `OSS_DEEP_LINKS` map in `src/features/apps/use-apps.ts` for native deep links). +- **Add or change Apps integrations** — see [Connecting third-party apps](#connecting-third-party-apps) above. Add a new card by inserting a row into `apps_catalog` with the matching `composio_toolkit_slug`, then add the corresponding `COMPOSIO_AUTH_CONFIG_` secret. +- **Send-to-Slack actions** — `functions/apps-slack-send-task.ts` formats and pushes a task to a chosen channel. Mirror the pattern to wire other outbound actions (post to Discord, open a GitHub issue, etc.) on top of Composio's tool execute API. - **Email invitations** — V1 produces a copyable link. To email instead, wire `insforge.emails.send()` in `src/features/users/use-invitations.ts`. --- diff --git a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql index 8e21830..eddb7f0 100644 --- a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql +++ b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql @@ -1,27 +1,19 @@ --- Composio Apps integration: catalog metadata + re-seed (no Zapier). --- See docs/superpowers/specs/2026-05-26-composio-apps-integration.md - -alter table public.apps_catalog - add column if not exists integration_kind text not null default 'composio' - check (integration_kind in ('composio', 'insforge_native')); +-- Composio Apps integration: catalog metadata + re-seed (composio-only). alter table public.apps_catalog add column if not exists composio_toolkit_slug text; -insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/slack.svg','composio', 'slack', 1), - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 2), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 3), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 4), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 5), - ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), - ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), - ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), - ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) +insert into public.apps_catalog (slug, name, description, icon_url, composio_toolkit_slug, display_order) values + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/slack.svg','slack', 1), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'github', 2), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'notion', 3), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord','discord', 4), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'figma', 5), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'linear', 6), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'vercel', 7) on conflict (slug) do update set - integration_kind = excluded.integration_kind, composio_toolkit_slug = excluded.composio_toolkit_slug, description = excluded.description, display_order = excluded.display_order; -delete from public.apps_catalog where slug = 'zapier'; +delete from public.apps_catalog where slug in ('zapier', 'stripe', 'openrouter'); diff --git a/admin-dashboard/migrations/db_init.sql b/admin-dashboard/migrations/db_init.sql index 9d908b5..9b13aff 100644 --- a/admin-dashboard/migrations/db_init.sql +++ b/admin-dashboard/migrations/db_init.sql @@ -84,8 +84,6 @@ create table if not exists public.apps_catalog ( description text not null, icon_url text, oauth_provider text, - integration_kind text not null default 'composio' - check (integration_kind in ('composio', 'insforge_native')), composio_toolkit_slug text, display_order integer not null default 0 ); @@ -610,14 +608,12 @@ create trigger messages_realtime -- SEED — apps_catalog -- ============================================================================ -insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/slack.svg','composio', 'slack', 1), - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 2), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 3), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 4), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 5), - ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), - ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), - ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), - ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) +insert into public.apps_catalog (slug, name, description, icon_url, composio_toolkit_slug, display_order) values + ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/slack.svg','slack', 1), + ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'github', 2), + ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'notion', 3), + ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord','discord', 4), + ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'figma', 5), + ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'linear', 6), + ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'vercel', 7) on conflict (slug) do nothing; diff --git a/admin-dashboard/src/features/apps/apps-grid.tsx b/admin-dashboard/src/features/apps/apps-grid.tsx index 62b5105..f38e499 100644 --- a/admin-dashboard/src/features/apps/apps-grid.tsx +++ b/admin-dashboard/src/features/apps/apps-grid.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Loader2 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -56,7 +56,6 @@ export function AppsGrid({ {apps.map((app) => { const connectPending = isConnectPending(app.slug) const disconnectPending = isDisconnectPending(app.slug) - const isNative = app.integration_kind === 'insforge_native' return ( @@ -85,22 +84,7 @@ export function AppsGrid({ - {isNative ? ( - <> - - Managed in InsForge dashboard - - - - ) : app.connected ? ( + {app.connected ? ( <> { const toolkits = new Set(appsConfig?.configured_toolkits ?? []) return apps.map((app) => { - if (app.integration_kind === 'insforge_native') return app const slug = app.composio_toolkit_slug const available = !!appsConfig?.composio_enabled && !!slug && toolkits.has(slug) return { ...app, is_available: available } }) }, [apps, appsConfig]) - const hasComposioApp = enrichedApps.some((a) => a.integration_kind === 'composio') const showSetupBanner = - !isConfigLoading && hasComposioApp && !appsConfig?.composio_enabled + !isConfigLoading && apps.length > 0 && !appsConfig?.composio_enabled const isConnectPending = (slug: string) => connect.isPending && connect.variables?.app.slug === slug @@ -53,8 +51,8 @@ export function AppsPage() {

Third-party integrations need Composio

- Stripe and OpenRouter work out of the box. To enable GitHub, Slack, Notion and - the other OAuth apps, provision Composio secrets on this project. + Provision Composio secrets on this project to enable GitHub, Slack, Notion, + and the other OAuth apps.

= { - stripe: '/payments', - openrouter: '/ai', -} - export type AppWithConnection = { slug: string name: string description: string icon_url: string | null display_order: number - integration_kind: IntegrationKind composio_toolkit_slug: string | null - oss_dashboard_path: string | null connected: boolean account_label: string | null is_available: boolean @@ -59,7 +49,7 @@ export function useApps(workspaceId: string | undefined) { insforge.database .from('apps_catalog') .select( - 'slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order', + 'slug, name, description, icon_url, composio_toolkit_slug, display_order', ) .order('display_order', { ascending: true }), insforge.database @@ -85,12 +75,10 @@ export function useApps(workspaceId: string | undefined) { description: app.description, icon_url: app.icon_url, display_order: app.display_order, - integration_kind: app.integration_kind, composio_toolkit_slug: app.composio_toolkit_slug, - oss_dashboard_path: OSS_DEEP_LINKS[app.slug] ?? null, connected: !!conn, account_label: conn?.config_json?.account_label ?? null, - is_available: app.integration_kind === 'insforge_native', + is_available: false, } }) }, diff --git a/admin-dashboard/src/features/apps/use-connect-app.ts b/admin-dashboard/src/features/apps/use-connect-app.ts index beeab45..5b6db46 100644 --- a/admin-dashboard/src/features/apps/use-connect-app.ts +++ b/admin-dashboard/src/features/apps/use-connect-app.ts @@ -5,7 +5,6 @@ import { appsKey, type AppWithConnection } from './use-apps' const POLL_INTERVAL_MS = 1500 const POLL_DEADLINE_MS = 120_000 -const OSS_BASE_URL = import.meta.env.VITE_INSFORGE_OSS_URL ?? 'https://app.insforge.dev' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -74,20 +73,11 @@ export function useConnectApp(workspaceId: string | undefined) { return useMutation({ mutationFn: async ({ app }: ConnectArgs): Promise => { if (!workspaceId) throw new Error('No active workspace') - - if (app.integration_kind === 'insforge_native') { - if (!app.oss_dashboard_path) throw new Error('No dashboard path for this app') - window.open(`${OSS_BASE_URL}${app.oss_dashboard_path}`, '_blank', 'noopener,noreferrer') - return - } - await connectComposio({ appSlug: app.slug, workspaceId }) }, onSuccess: (_data, { app }) => { - if (app.integration_kind === 'composio') { - toast.success(`${app.name} connected`) - void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) - } + toast.success(`${app.name} connected`) + void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) }, onError: (err: Error) => toast.error(err.message), }) diff --git a/admin-dashboard/src/routeTree.gen.ts b/admin-dashboard/src/routeTree.gen.ts index 39839a3..e09043c 100644 --- a/admin-dashboard/src/routeTree.gen.ts +++ b/admin-dashboard/src/routeTree.gen.ts @@ -20,7 +20,6 @@ import { Route as errors404RouteImport } from './routes/(errors)/404' import { Route as errors403RouteImport } from './routes/(errors)/403' import { Route as errors401RouteImport } from './routes/(errors)/401' import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up' -import { Route as authSignIn2RouteImport } from './routes/(auth)/sign-in-2' import { Route as authSignInRouteImport } from './routes/(auth)/sign-in' import { Route as authOtpRouteImport } from './routes/(auth)/otp' import { Route as authForgotPasswordRouteImport } from './routes/(auth)/forgot-password' @@ -92,11 +91,6 @@ const authSignUpRoute = authSignUpRouteImport.update({ path: '/sign-up', getParentRoute: () => rootRouteImport, } as any) -const authSignIn2Route = authSignIn2RouteImport.update({ - id: '/(auth)/sign-in-2', - path: '/sign-in-2', - getParentRoute: () => rootRouteImport, -} as any) const authSignInRoute = authSignInRouteImport.update({ id: '/(auth)/sign-in', path: '/sign-in', @@ -193,7 +187,6 @@ export interface FileRoutesByFullPath { '/forgot-password': typeof authForgotPasswordRoute '/otp': typeof authOtpRoute '/sign-in': typeof authSignInRoute - '/sign-in-2': typeof authSignIn2Route '/sign-up': typeof authSignUpRoute '/401': typeof errors401Route '/403': typeof errors403Route @@ -221,7 +214,6 @@ export interface FileRoutesByTo { '/forgot-password': typeof authForgotPasswordRoute '/otp': typeof authOtpRoute '/sign-in': typeof authSignInRoute - '/sign-in-2': typeof authSignIn2Route '/sign-up': typeof authSignUpRoute '/401': typeof errors401Route '/403': typeof errors403Route @@ -252,7 +244,6 @@ export interface FileRoutesById { '/(auth)/forgot-password': typeof authForgotPasswordRoute '/(auth)/otp': typeof authOtpRoute '/(auth)/sign-in': typeof authSignInRoute - '/(auth)/sign-in-2': typeof authSignIn2Route '/(auth)/sign-up': typeof authSignUpRoute '/(errors)/401': typeof errors401Route '/(errors)/403': typeof errors403Route @@ -283,7 +274,6 @@ export interface FileRouteTypes { | '/forgot-password' | '/otp' | '/sign-in' - | '/sign-in-2' | '/sign-up' | '/401' | '/403' @@ -311,7 +301,6 @@ export interface FileRouteTypes { | '/forgot-password' | '/otp' | '/sign-in' - | '/sign-in-2' | '/sign-up' | '/401' | '/403' @@ -341,7 +330,6 @@ export interface FileRouteTypes { | '/(auth)/forgot-password' | '/(auth)/otp' | '/(auth)/sign-in' - | '/(auth)/sign-in-2' | '/(auth)/sign-up' | '/(errors)/401' | '/(errors)/403' @@ -371,7 +359,6 @@ export interface RootRouteChildren { authForgotPasswordRoute: typeof authForgotPasswordRoute authOtpRoute: typeof authOtpRoute authSignInRoute: typeof authSignInRoute - authSignIn2Route: typeof authSignIn2Route authSignUpRoute: typeof authSignUpRoute errors401Route: typeof errors401Route errors403Route: typeof errors403Route @@ -461,13 +448,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof authSignUpRouteImport parentRoute: typeof rootRouteImport } - '/(auth)/sign-in-2': { - id: '/(auth)/sign-in-2' - path: '/sign-in-2' - fullPath: '/sign-in-2' - preLoaderRoute: typeof authSignIn2RouteImport - parentRoute: typeof rootRouteImport - } '/(auth)/sign-in': { id: '/(auth)/sign-in' path: '/sign-in' @@ -639,7 +619,6 @@ const rootRouteChildren: RootRouteChildren = { authForgotPasswordRoute: authForgotPasswordRoute, authOtpRoute: authOtpRoute, authSignInRoute: authSignInRoute, - authSignIn2Route: authSignIn2Route, authSignUpRoute: authSignUpRoute, errors401Route: errors401Route, errors403Route: errors403Route, diff --git a/admin-dashboard/src/routes/(auth)/sign-in-2.tsx b/admin-dashboard/src/routes/(auth)/sign-in-2.tsx deleted file mode 100644 index e7fa579..0000000 --- a/admin-dashboard/src/routes/(auth)/sign-in-2.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' -import { useState } from 'react' -import { toast } from 'sonner' -import { Github } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { insforge } from '@/lib/insforge' -import { useAuth } from '@/lib/auth-context' -import { ensureWorkspace } from '@/features/auth/ensure-workspace' -import { useWorkspaceStore } from '@/features/workspaces/workspace-store' - -export const Route = createFileRoute('/(auth)/sign-in-2')({ - component: SignIn2Page, -}) - -function SignIn2Page() { - const navigate = useNavigate() - const { refresh } = useAuth() - const setActiveWorkspaceId = useWorkspaceStore((s) => s.setActiveWorkspaceId) - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setLoading(true) - const { data, error } = await insforge.auth.signInWithPassword({ email, password }) - setLoading(false) - if (error || !data?.user) { - toast.error(error?.message ?? 'Sign in failed') - return - } - const user = data.user as { id: string; email: string } - const wsId = await ensureWorkspace(user.id, user.email) - setActiveWorkspaceId(wsId) - await refresh() - navigate({ to: '/dashboard' }) - } - - const onOAuth = async (provider: 'google' | 'github') => { - const { data, error } = await insforge.auth.signInWithOAuth({ - provider, - redirectTo: `${window.location.origin}/auth/callback`, - skipBrowserRedirect: true, - }) - if (error || !data?.url) { - toast.error(error?.message ?? `${provider} sign-in is not configured`) - return - } - window.location.href = data.url - } - - return ( -
-
-
-
-

Manage your workspace

-

Sidebar, charts, tables, settings, and real-time chat — wired to InsForge auth, DB, RLS, storage, and realtime.

-
-
-
-
-
-

Sign in

-

Use the email and password for your workspace.

-
-
-
- - setEmail(e.target.value)} autoComplete="email" /> -
-
- - setPassword(e.target.value)} autoComplete="current-password" /> -
- -
-
-
- -
-
- Or continue with -
-
-
- - -
-

- New here?{' '} - - Create an account - -

-
-
-
- ) -} diff --git a/registry.json b/registry.json index 0a0e945..b3f4c85 100644 --- a/registry.json +++ b/registry.json @@ -67,11 +67,11 @@ { "slug": "admin-dashboard", "name": "Admin Dashboard Starter", - "description": "Multi-user SaaS admin with workspace invites, TanStack Table CRUD, real-time chat, charts, and full settings. Vite + React + InsForge auth, database, RLS, storage, and realtime.", + "description": "Multi-user SaaS admin with workspace invites, sortable tables, real-time chat, charts, and a Composio-powered apps grid that sends tasks to Slack with two clicks. Vite + React + InsForge auth, database, RLS, storage, and realtime.", "category": "admin", "framework": "react", - "features": ["auth", "database", "storage", "realtime"], - "tags": ["admin", "dashboard", "shadcn", "tanstack-table", "workspace", "saas"], + "features": ["auth", "database", "storage", "realtime", "composio", "slack-outbound"], + "tags": ["admin", "dashboard", "shadcn", "tanstack-table", "workspace", "saas", "composio", "slack", "oauth"], "cover": "assets/covers/admin-dashboard.png", "demo_url": "https://admindashboard.insforge.site", "author": "InsForge", From 94210e0e9382321d5edf258577277e3931eba9d1 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 13:54:16 -0700 Subject: [PATCH 13/15] admin-dashboard: stop tracking superpowers spec/plan docs --- admin-dashboard/.gitignore | 1 + .../2026-05-26-composio-apps-integration.md | 1198 ----------------- .../2026-05-26-composio-apps-integration.md | 470 ------- 3 files changed, 1 insertion(+), 1668 deletions(-) delete mode 100644 admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md delete mode 100644 admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md diff --git a/admin-dashboard/.gitignore b/admin-dashboard/.gitignore index ff563b2..2c5abb9 100644 --- a/admin-dashboard/.gitignore +++ b/admin-dashboard/.gitignore @@ -37,3 +37,4 @@ dist-ssr # Build *.tsbuildinfo .tmp +docs/superpowers diff --git a/admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md b/admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md deleted file mode 100644 index ca3bf63..0000000 --- a/admin-dashboard/docs/superpowers/plans/2026-05-26-composio-apps-integration.md +++ /dev/null @@ -1,1198 +0,0 @@ -# Composio Apps Integration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the stub toggle on the admin-dashboard `/apps` page with real OAuth-backed connection flows for 7 SaaS integrations (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) brokered by Composio Connected Accounts. Stripe + OpenRouter become deep-links to the OSS dashboard. Zapier is removed. - -**Architecture:** Three InsForge edge functions (`apps-connect`, `apps-poll`, `apps-disconnect`) wrap Composio's REST API and write the Composio `connected_account_id` into `app_connections.config_json` using the caller's RLS context. The SPA opens a popup to Composio's hosted OAuth URL and polls the `apps-poll` function until `ACTIVE`. Stripe/OpenRouter cards branch to a `window.open(VITE_INSFORGE_OSS_URL + path)` flow with no DB row. - -**Tech Stack:** Vite + React 19 + TanStack Query + TanStack Router; `@insforge/sdk` for the SPA; Deno Subhosting (`npm:@insforge/sdk`) + Composio REST API for the edge functions. - -**Verification gate:** This template has no test framework (matching other InsForge templates), so each implementation task ends with `npm run typecheck` + `npm run lint` rather than unit tests. A final smoke-test task (Task 10) drives the end-to-end flow in a browser. - -**Reference spec:** `../specs/2026-05-26-composio-apps-integration.md`. The plan deliberately diverges from the spec in two places (called out where they occur): - -- **No `_shared.ts` for functions.** InsForge functions deploy one file at a time and don't share modules. CORS + auth helpers inline in each function. -- **No `app_connections` rows for `insforge_native` apps.** The Stripe/OpenRouter cards are static deep-links; their "connected" state is never persisted (the OSS dashboard owns that state). Only Composio apps write to `app_connections`. - ---- - -## File Map - -**Create:** -- `admin-dashboard/migrations/_composio-apps-integration.sql` — schema additions + re-seed -- `admin-dashboard/functions/apps-connect.ts` — initiate Composio Connected Account -- `admin-dashboard/functions/apps-poll.ts` — poll status, on ACTIVE upsert to `app_connections` -- `admin-dashboard/functions/apps-disconnect.ts` — revoke Composio + delete row -- `admin-dashboard/src/features/apps/use-connect-app.ts` — Composio popup flow + deep-link branch -- `admin-dashboard/src/features/apps/use-disconnect-app.ts` — Composio revoke - -**Modify:** -- `admin-dashboard/src/features/apps/use-apps.ts` — surface `integration_kind`, `account_label`, `oss_dashboard_path` -- `admin-dashboard/src/features/apps/apps-grid.tsx` — replace `` with kind-aware buttons -- `admin-dashboard/src/features/apps/apps-page.tsx` — wire to `useConnectApp` / `useDisconnectApp` -- `admin-dashboard/README.md` — replace "Wire a real integration" section with Composio setup checklist -- `admin-dashboard/.env.example` — add `VITE_INSFORGE_OSS_URL` - -**Delete:** -- `admin-dashboard/src/features/apps/use-toggle-app.ts` (replaced by the two new hooks) - ---- - -## Task 1: Migration — schema + re-seed - -**Files:** -- Create: `admin-dashboard/migrations/_composio-apps-integration.sql` - -- [ ] **Step 1: Generate the migration file with the InsForge CLI** - -The CLI enforces `_.sql` and rejects other formats. Run from `admin-dashboard/`: - -```bash -cd admin-dashboard -npx @insforge/cli db migrations new composio-apps-integration -``` - -This prints the created path (e.g. `migrations/20260526143000_composio-apps-integration.sql`). Note the actual filename — subsequent steps refer to it as ``. - -- [ ] **Step 2: Write the migration body** - -Replace the empty file contents with exactly: - -```sql --- Composio Apps integration: catalog metadata + re-seed (no Zapier). --- See docs/superpowers/specs/2026-05-26-composio-apps-integration.md - -alter table public.apps_catalog - add column if not exists integration_kind text not null default 'composio' - check (integration_kind in ('composio', 'insforge_native')); - -alter table public.apps_catalog - add column if not exists composio_toolkit_slug text; - --- Re-seed: Zapier removed, integration_kind + composio_toolkit_slug populated. -insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), - ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), - ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), - ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), - ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) -on conflict (slug) do update set - integration_kind = excluded.integration_kind, - composio_toolkit_slug = excluded.composio_toolkit_slug, - description = excluded.description, - display_order = excluded.display_order; - -delete from public.apps_catalog where slug = 'zapier'; - --- Also update the canonical db_init.sql seed inline so fresh installs start correct. --- (No-op SQL: this comment documents that Step 3 below edits db_init.sql.) -``` - -- [ ] **Step 3: Update `migrations/db_init.sql` to match (fresh installs)** - -Open `admin-dashboard/migrations/db_init.sql` and find the `INSERT INTO public.apps_catalog` block at line ~610 (the one with the 10 service rows). Replace lines 610-621 (entire block including trailing `on conflict ... do nothing;`) with this new block: - -```sql -insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), - ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), - ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), - ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), - ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) -on conflict (slug) do nothing; -``` - -Also find the `create table if not exists public.apps_catalog (` block at line 81 and add two new columns inside the parens (after `oauth_provider text,` and before `display_order integer not null default 0`): - -```sql - integration_kind text not null default 'composio' - check (integration_kind in ('composio', 'insforge_native')), - composio_toolkit_slug text, -``` - -The result should have 8 columns total: `slug, name, description, icon_url, oauth_provider, integration_kind, composio_toolkit_slug, display_order`. Leave `oauth_provider` in place — it's dead but removing it is a destructive change that's out of scope here. - -- [ ] **Step 4: Apply the migration to the linked dev backend** - -```bash -cd admin-dashboard -npx @insforge/cli db migrations up --all -``` - -Expected output: `Applied 1 migration: `. If you see `Already up to date`, the migration was not picked up — check the filename matches the CLI's naming rule. - -- [ ] **Step 5: Verify schema applied** - -```bash -npx @insforge/cli db query "select slug, integration_kind, composio_toolkit_slug from public.apps_catalog order by display_order" -``` - -Expected: 9 rows, `stripe` and `openrouter` have `integration_kind=insforge_native`, the other 7 have `integration_kind=composio` with their toolkit slug populated, and **no `zapier` row**. - -- [ ] **Step 6: Commit** - -```bash -cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template -git add admin-dashboard/migrations/ admin-dashboard/migrations/db_init.sql -git commit -m "admin-dashboard: schema for composio apps integration" -``` - ---- - -## Task 2: Edge function — `apps-connect` - -**Files:** -- Create: `admin-dashboard/functions/apps-connect.ts` - -This function takes `{ app_slug, workspace_id }`, looks up the toolkit's `auth_config_id` from secrets, calls Composio's `connectedAccounts/initiate`, and returns the redirect URL + request id. - -- [ ] **Step 1: Create the file with this exact contents** - -```typescript -import { createClient } from 'npm:@insforge/sdk' - -// CORS — every InsForge function needs this for browser-invoked use. -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', -} - -const json = (status: number, body: unknown) => - new Response(JSON.stringify(body), { - status, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }) - -const err = (status: number, code: string, detail?: string) => - json(status, { error: code, detail }) - -export default async function handler(req: Request): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: corsHeaders }) - } - if (req.method !== 'POST') return err(405, 'method_not_allowed') - - // RLS-scoped client using the caller's JWT. - const authHeader = req.headers.get('Authorization') - const userToken = authHeader ? authHeader.replace('Bearer ', '') : null - if (!userToken) return err(401, 'unauthorized') - - const client = createClient({ - baseUrl: Deno.env.get('INSFORGE_BASE_URL'), - edgeFunctionToken: userToken, - }) - - const { data: userData } = await client.auth.getCurrentUser() - if (!userData?.user?.id) return err(401, 'unauthorized') - - let body: { app_slug?: string; workspace_id?: string } - try { - body = await req.json() - } catch { - return err(400, 'invalid_json') - } - const { app_slug, workspace_id } = body - if (!app_slug || !workspace_id) return err(400, 'missing_fields') - - // Look up the toolkit slug. RLS allows any authenticated read on apps_catalog. - const { data: appRow, error: appErr } = await client.database - .from('apps_catalog') - .select('integration_kind, composio_toolkit_slug') - .eq('slug', app_slug) - .single() - if (appErr || !appRow) return err(404, 'app_not_found', appErr?.message) - if (appRow.integration_kind !== 'composio' || !appRow.composio_toolkit_slug) { - return err(400, 'not_a_composio_app') - } - - const authConfigId = Deno.env.get( - `COMPOSIO_AUTH_CONFIG_${appRow.composio_toolkit_slug.toUpperCase()}`, - ) - if (!authConfigId) return err(500, 'auth_config_not_provisioned') - - const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') - if (!composioApiKey) return err(500, 'composio_api_key_missing') - - // Composio v3 REST: initiate a connected account request. - // Docs: https://docs.composio.dev/api-reference/v3/connected-accounts - const initRes = await fetch('https://backend.composio.dev/api/v3/connected_accounts/initiate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': composioApiKey, - }, - body: JSON.stringify({ - auth_config_id: authConfigId, - // One Composio "user" per InsForge workspace — connections are workspace-scoped. - user_id: workspace_id, - }), - }) - - if (!initRes.ok) { - const text = await initRes.text() - return err(502, 'composio_initiate_failed', text) - } - const init = (await initRes.json()) as { id: string; redirect_url?: string; redirectUrl?: string } - - return json(200, { - request_id: init.id, - redirect_url: init.redirect_url ?? init.redirectUrl, - }) -} -``` - -> **Confirm the exact field names** for the Composio v3 initiate endpoint (`auth_config_id` vs `authConfigId`, `user_id` vs `userId`, `redirect_url` vs `redirectUrl`) by running `curl -H "x-api-key: $COMPOSIO_API_KEY" https://backend.composio.dev/api/v3/connected_accounts/initiate -d '{...}' -i` against a real `auth_config_id` once secrets are provisioned. If a field name differs from what's coded above, fix it in this file and propagate to `apps-poll.ts` (Task 3) and `apps-disconnect.ts` (Task 4) before deploying. - -- [ ] **Step 2: Deploy the function** - -```bash -cd admin-dashboard -npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts --description "Composio Connected Account initiate" -``` - -Expected: `Function apps-connect deployed (status: active)`. If status is `error`, run `npx @insforge/cli functions code apps-connect` to inspect what landed and `npx @insforge/cli logs function-deploy.logs --limit 10` to see why. - -- [ ] **Step 3: Verify it deployed** - -```bash -npx @insforge/cli functions list | grep apps-connect -``` - -Expected: `apps-connect | active`. - -- [ ] **Step 4: Commit** - -```bash -cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template -git add admin-dashboard/functions/apps-connect.ts -git commit -m "admin-dashboard: apps-connect edge function for composio oauth initiate" -``` - ---- - -## Task 3: Edge function — `apps-poll` - -**Files:** -- Create: `admin-dashboard/functions/apps-poll.ts` - -Polls the Composio connection by request_id. On `ACTIVE`, upserts into `app_connections` with `config_json` populated. - -- [ ] **Step 1: Create the file** - -```typescript -import { createClient } from 'npm:@insforge/sdk' - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', -} - -const json = (status: number, body: unknown) => - new Response(JSON.stringify(body), { - status, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }) - -const err = (status: number, code: string, detail?: string) => - json(status, { error: code, detail }) - -export default async function handler(req: Request): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: corsHeaders }) - } - if (req.method !== 'GET') return err(405, 'method_not_allowed') - - const authHeader = req.headers.get('Authorization') - const userToken = authHeader ? authHeader.replace('Bearer ', '') : null - if (!userToken) return err(401, 'unauthorized') - - const client = createClient({ - baseUrl: Deno.env.get('INSFORGE_BASE_URL'), - edgeFunctionToken: userToken, - }) - - const { data: userData } = await client.auth.getCurrentUser() - if (!userData?.user?.id) return err(401, 'unauthorized') - - const url = new URL(req.url) - const request_id = url.searchParams.get('request_id') - const app_slug = url.searchParams.get('app_slug') - const workspace_id = url.searchParams.get('workspace_id') - if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') - - const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') - if (!composioApiKey) return err(500, 'composio_api_key_missing') - - const res = await fetch( - `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(request_id)}`, - { headers: { 'x-api-key': composioApiKey } }, - ) - if (!res.ok) return err(502, 'composio_get_failed', await res.text()) - - const account = (await res.json()) as { - id: string - status: string - data?: Record - created_at?: string - } - - // Composio v3 statuses: INITIATED, ACTIVE, FAILED, INACTIVE, EXPIRED, REVOKED. - if (account.status === 'INITIATED') return json(200, { status: 'pending' }) - if (account.status !== 'ACTIVE') { - return json(200, { status: 'failed', detail: account.status }) - } - - // Build a friendly label from whatever Composio surfaced. Different toolkits put - // different shapes in account.data — fall back gracefully. - const dataObj = (account.data ?? {}) as Record - const account_label = - (dataObj.account_label as string | undefined) ?? - (dataObj.user_email as string | undefined) ?? - (dataObj.email as string | undefined) ?? - (dataObj.login as string | undefined) ?? - (dataObj.name as string | undefined) ?? - null - - const { error: upsertErr } = await client.database - .from('app_connections') - .upsert( - { - workspace_id, - app_slug, - status: 'connected', - connected_by: userData.user.id, - connected_at: new Date().toISOString(), - config_json: { - kind: 'composio', - connected_account_id: account.id, - account_label, - connected_at_provider: account.created_at ?? null, - }, - }, - { onConflict: 'workspace_id,app_slug' }, - ) - - if (upsertErr) return err(500, 'db_upsert_failed', upsertErr.message) - - return json(200, { status: 'connected', account_label }) -} -``` - -> Same caveat as Task 2: confirm Composio field names (`status` enum values, `data` shape, `created_at` vs `createdAt`) by inspecting one real `GET /connected_accounts/{id}` response after the first end-to-end auth in Task 10. Adjust this file if reality differs. - -- [ ] **Step 2: Deploy** - -```bash -cd admin-dashboard -npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts --description "Composio Connected Account status poll + persist" -``` - -- [ ] **Step 3: Verify** - -```bash -npx @insforge/cli functions list | grep apps-poll -``` - -Expected: `apps-poll | active`. - -- [ ] **Step 4: Commit** - -```bash -cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template -git add admin-dashboard/functions/apps-poll.ts -git commit -m "admin-dashboard: apps-poll edge function for composio status + persist" -``` - ---- - -## Task 4: Edge function — `apps-disconnect` - -**Files:** -- Create: `admin-dashboard/functions/apps-disconnect.ts` - -Revokes the Composio connection (best-effort) and deletes the row from `app_connections`. - -- [ ] **Step 1: Create the file** - -```typescript -import { createClient } from 'npm:@insforge/sdk' - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', -} - -const json = (status: number, body: unknown) => - new Response(JSON.stringify(body), { - status, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }) - -const err = (status: number, code: string, detail?: string) => - json(status, { error: code, detail }) - -export default async function handler(req: Request): Promise { - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: corsHeaders }) - } - if (req.method !== 'POST') return err(405, 'method_not_allowed') - - const authHeader = req.headers.get('Authorization') - const userToken = authHeader ? authHeader.replace('Bearer ', '') : null - if (!userToken) return err(401, 'unauthorized') - - const client = createClient({ - baseUrl: Deno.env.get('INSFORGE_BASE_URL'), - edgeFunctionToken: userToken, - }) - - const { data: userData } = await client.auth.getCurrentUser() - if (!userData?.user?.id) return err(401, 'unauthorized') - - let body: { app_slug?: string; workspace_id?: string } - try { - body = await req.json() - } catch { - return err(400, 'invalid_json') - } - const { app_slug, workspace_id } = body - if (!app_slug || !workspace_id) return err(400, 'missing_fields') - - // RLS scopes this select to the caller's workspace memberships automatically. - const { data: existing } = await client.database - .from('app_connections') - .select('config_json') - .eq('workspace_id', workspace_id) - .eq('app_slug', app_slug) - .maybeSingle() - - const composioId = - (existing?.config_json as Record | null | undefined)?.connected_account_id as - | string - | undefined - - if (composioId) { - const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') - if (composioApiKey) { - // Best-effort: a 404 from Composio means the connection is already gone. - await fetch( - `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(composioId)}`, - { method: 'DELETE', headers: { 'x-api-key': composioApiKey } }, - ).catch(() => {}) - } - } - - const { error: delErr } = await client.database - .from('app_connections') - .delete() - .eq('workspace_id', workspace_id) - .eq('app_slug', app_slug) - if (delErr) return err(500, 'db_delete_failed', delErr.message) - - return json(200, { ok: true }) -} -``` - -- [ ] **Step 2: Deploy** - -```bash -cd admin-dashboard -npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts --description "Composio Connected Account revoke + cleanup" -``` - -- [ ] **Step 3: Verify** - -```bash -npx @insforge/cli functions list | grep apps-disconnect -``` - -Expected: `apps-disconnect | active`. - -- [ ] **Step 4: Commit** - -```bash -cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template -git add admin-dashboard/functions/apps-disconnect.ts -git commit -m "admin-dashboard: apps-disconnect edge function for composio revoke" -``` - ---- - -## Task 5: Frontend — extend `use-apps.ts` data shape - -**Files:** -- Modify: `admin-dashboard/src/features/apps/use-apps.ts` - -Surface `integration_kind`, `composio_toolkit_slug`, `account_label` (from `config_json`), and a computed `oss_dashboard_path` for native apps. - -- [ ] **Step 1: Replace the file's contents with this exact code** - -```typescript -import { useQuery } from '@tanstack/react-query' -import { insforge } from '@/lib/insforge' - -export type IntegrationKind = 'composio' | 'insforge_native' - -export type App = { - slug: string - name: string - description: string - icon_url: string | null - integration_kind: IntegrationKind - composio_toolkit_slug: string | null - display_order: number -} - -type ConnectionConfig = { - kind?: 'composio' - connected_account_id?: string - account_label?: string | null -} - -export type AppConnection = { - id: string - workspace_id: string - app_slug: string - status: 'connected' | 'disconnected' - connected_at: string - connected_by: string - config_json: ConnectionConfig | null -} - -// Deep-link targets in the OSS dashboard for the two native apps. -const OSS_DEEP_LINKS: Record = { - stripe: '/payments', - openrouter: '/ai', -} - -export type AppWithConnection = { - slug: string - name: string - description: string - icon_url: string | null - display_order: number - integration_kind: IntegrationKind - composio_toolkit_slug: string | null - oss_dashboard_path: string | null - connected: boolean - account_label: string | null -} - -export const appsKey = (workspaceId: string | undefined) => ['apps', workspaceId] as const - -export function useApps(workspaceId: string | undefined) { - return useQuery({ - enabled: !!workspaceId, - queryKey: appsKey(workspaceId), - queryFn: async (): Promise => { - const [catalogRes, connectionsRes] = await Promise.all([ - insforge.database - .from('apps_catalog') - .select( - 'slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order', - ) - .order('display_order', { ascending: true }), - insforge.database - .from('app_connections') - .select('id, workspace_id, app_slug, status, connected_at, connected_by, config_json') - .eq('workspace_id', workspaceId!), - ]) - - if (catalogRes.error) throw new Error(catalogRes.error.message) - if (connectionsRes.error) throw new Error(connectionsRes.error.message) - - const catalog = (catalogRes.data ?? []) as App[] - const connections = (connectionsRes.data ?? []) as AppConnection[] - const bySlug = new Map(connections.filter((c) => c.status === 'connected').map((c) => [c.app_slug, c])) - - return catalog.map((app) => { - const conn = bySlug.get(app.slug) - return { - slug: app.slug, - name: app.name, - description: app.description, - icon_url: app.icon_url, - display_order: app.display_order, - integration_kind: app.integration_kind, - composio_toolkit_slug: app.composio_toolkit_slug, - oss_dashboard_path: OSS_DEEP_LINKS[app.slug] ?? null, - connected: !!conn, - account_label: conn?.config_json?.account_label ?? null, - } - }) - }, - }) -} -``` - -- [ ] **Step 2: Typecheck** - -```bash -cd admin-dashboard && npm run typecheck -``` - -Expected: passes. If the `apps-grid.tsx` or `apps-page.tsx` consumers complain about missing properties, that's expected — Task 7 fixes the grid and Task 8 fixes the page. Note any errors involving files **other than** those two — those would indicate a regression. - -> **Do not commit yet** — leave the working tree dirty so Tasks 6-8 land alongside as a single coherent change. The intermediate state will not typecheck. - ---- - -## Task 6: Frontend — create `use-connect-app.ts` - -**Files:** -- Create: `admin-dashboard/src/features/apps/use-connect-app.ts` - -A single mutation that branches on `integration_kind`. For `composio`: opens popup → polls. For `insforge_native`: opens the OSS dashboard deep-link in a new tab. - -- [ ] **Step 1: Create the file** - -```typescript -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' -import { insforge } from '@/lib/insforge' -import { appsKey, type AppWithConnection } from './use-apps' - -const POLL_INTERVAL_MS = 1500 -const POLL_DEADLINE_MS = 120_000 // 2 minutes -const OSS_BASE_URL = import.meta.env.VITE_INSFORGE_OSS_URL ?? 'https://app.insforge.dev' - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -type ConnectArgs = { app: AppWithConnection } - -async function connectComposio(args: { - appSlug: string - workspaceId: string -}): Promise<{ account_label: string | null }> { - // Open the popup synchronously to avoid being blocked. The URL is rewritten - // once the initiate call resolves. - const popup = window.open('about:blank', 'composio-oauth', 'width=600,height=720') - if (!popup) throw new Error('Popup blocked — allow popups for this site and try again.') - - try { - const { data: initData, error: initErr } = await insforge.functions.invoke('apps-connect', { - body: { app_slug: args.appSlug, workspace_id: args.workspaceId }, - }) - if (initErr) throw new Error(initErr.message ?? 'Failed to start authorization') - - const { redirect_url, request_id } = initData as { redirect_url: string; request_id: string } - if (!redirect_url || !request_id) throw new Error('Composio did not return a redirect URL') - - popup.location.href = redirect_url - - const deadline = Date.now() + POLL_DEADLINE_MS - while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS) - - const { data: pollData, error: pollErr } = await insforge.functions.invoke('apps-poll', { - method: 'GET', - body: { - request_id, - app_slug: args.appSlug, - workspace_id: args.workspaceId, - }, - }) - if (pollErr) throw new Error(pollErr.message ?? 'Polling failed') - - const result = pollData as { status: 'pending' | 'connected' | 'failed'; account_label?: string | null; detail?: string } - if (result.status === 'connected') { - return { account_label: result.account_label ?? null } - } - if (result.status === 'failed') { - throw new Error(`Authorization failed (${result.detail ?? 'unknown'})`) - } - // Treat user-closed popup as cancellation only after the next poll comes back pending. - if (popup.closed && result.status === 'pending') { - throw new Error('Authorization window was closed before completion') - } - } - throw new Error('Authorization timed out after 2 minutes') - } finally { - if (!popup.closed) popup.close() - } -} - -export function useConnectApp(workspaceId: string | undefined) { - const qc = useQueryClient() - - return useMutation({ - mutationFn: async ({ app }: ConnectArgs): Promise => { - if (!workspaceId) throw new Error('No active workspace') - - if (app.integration_kind === 'insforge_native') { - if (!app.oss_dashboard_path) throw new Error('No dashboard path for this app') - window.open(`${OSS_BASE_URL}${app.oss_dashboard_path}`, '_blank', 'noopener,noreferrer') - return - } - - await connectComposio({ appSlug: app.slug, workspaceId }) - }, - onSuccess: (_data, { app }) => { - if (app.integration_kind === 'composio') { - toast.success(`${app.name} connected`) - void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) - } - // For insforge_native we just opened a new tab — no toast needed. - }, - onError: (err: Error) => toast.error(err.message), - }) -} -``` - -> Note: `insforge.functions.invoke` with `method: 'GET'` still serializes `body` into the request — confirm this against the SDK behavior. If GET + body is not supported, change `apps-poll` to accept POST and update both ends accordingly. - -- [ ] **Step 2: Typecheck** - -```bash -cd admin-dashboard && npm run typecheck -``` - -Expected: passes for this file. Pre-existing errors in `apps-grid.tsx` / `apps-page.tsx` remain (fixed in Tasks 7-8). - ---- - -## Task 7: Frontend — create `use-disconnect-app.ts` - -**Files:** -- Create: `admin-dashboard/src/features/apps/use-disconnect-app.ts` - -- [ ] **Step 1: Create the file** - -```typescript -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' -import { insforge } from '@/lib/insforge' -import { appsKey, type AppWithConnection } from './use-apps' - -type DisconnectArgs = { app: AppWithConnection } - -export function useDisconnectApp(workspaceId: string | undefined) { - const qc = useQueryClient() - - return useMutation({ - mutationFn: async ({ app }: DisconnectArgs): Promise => { - if (!workspaceId) throw new Error('No active workspace') - if (app.integration_kind !== 'composio') { - throw new Error('Native integrations are managed in the InsForge dashboard') - } - const { error } = await insforge.functions.invoke('apps-disconnect', { - body: { app_slug: app.slug, workspace_id: workspaceId }, - }) - if (error) throw new Error(error.message ?? 'Disconnect failed') - }, - onSuccess: (_data, { app }) => { - toast.success(`${app.name} disconnected`) - void qc.invalidateQueries({ queryKey: appsKey(workspaceId) }) - }, - onError: (err: Error) => toast.error(err.message), - }) -} -``` - -- [ ] **Step 2: Delete the obsolete hook** - -```bash -rm admin-dashboard/src/features/apps/use-toggle-app.ts -``` - ---- - -## Task 8: Frontend — rewrite `apps-grid.tsx` - -**Files:** -- Modify: `admin-dashboard/src/features/apps/apps-grid.tsx` - -Drop the `Switch`. Render kind-aware actions: Composio cards get "Connect" / "Connecting…" / "Connected as X + Disconnect"; native cards get "Configure in dashboard" only. - -- [ ] **Step 1: Replace the file's contents** - -```typescript -import { ExternalLink, Loader2 } from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Skeleton } from '@/components/ui/skeleton' -import type { AppWithConnection } from './use-apps' - -type Props = { - apps: AppWithConnection[] - isLoading: boolean - isConnectPending: (slug: string) => boolean - isDisconnectPending: (slug: string) => boolean - onConnect: (app: AppWithConnection) => void - onDisconnect: (app: AppWithConnection) => void -} - -export function AppsGrid({ - apps, - isLoading, - isConnectPending, - isDisconnectPending, - onConnect, - onDisconnect, -}: Props) { - if (isLoading) { - return ( -
- {Array.from({ length: 6 }, (_, i) => ( - - -
- - -
- - -
-
- ))} -
- ) - } - - if (apps.length === 0) { - return

No apps available.

- } - - return ( -
- {apps.map((app) => { - const connectPending = isConnectPending(app.slug) - const disconnectPending = isDisconnectPending(app.slug) - const isNative = app.integration_kind === 'insforge_native' - - return ( - - {app.connected && ( - - Connected - - )} - -
- {app.icon_url ? ( - {`${app.name} - ) : ( -
- )} - {app.name} -
- - {app.description} - - - - - {isNative ? ( - <> - - Managed in InsForge dashboard - - - - ) : app.connected ? ( - <> - - {app.account_label ? `as ${app.account_label}` : 'Active integration'} - - - - ) : ( - <> - Not connected - - - )} - - - ) - })} -
- ) -} -``` - -- [ ] **Step 2: Typecheck** - -```bash -cd admin-dashboard && npm run typecheck -``` - -Expected: this file passes. The remaining error should now be in `apps-page.tsx`, fixed in Task 9. - ---- - -## Task 9: Frontend — wire `apps-page.tsx` - -**Files:** -- Modify: `admin-dashboard/src/features/apps/apps-page.tsx` - -- [ ] **Step 1: Read the current contents** to learn what props were being passed. - -```bash -cat admin-dashboard/src/features/apps/apps-page.tsx -``` - -- [ ] **Step 2: Edit the file** so it uses the new hooks and passes the new `AppsGrid` props. - -Replace the import of `useToggleApp` with the two new hooks, instantiate both, and pass `isConnectPending`, `isDisconnectPending`, `onConnect`, `onDisconnect` instead of the old `isPending` + `onToggle`. The pending-checker pattern: - -```typescript -const connect = useConnectApp(workspaceId) -const disconnect = useDisconnectApp(workspaceId) - -const isConnectPending = (slug: string) => - connect.isPending && connect.variables?.app.slug === slug -const isDisconnectPending = (slug: string) => - disconnect.isPending && disconnect.variables?.app.slug === slug - -return ( - connect.mutate({ app })} - onDisconnect={(app) => disconnect.mutate({ app })} - /> -) -``` - -Keep all other page chrome (header, description, error states) exactly as it was. - -- [ ] **Step 3: Typecheck + lint** - -```bash -cd admin-dashboard && npm run typecheck && npm run lint -``` - -Expected: both pass. If `lint` reports `no-unused-vars` for any leftover import, remove the import. - -- [ ] **Step 4: Build** - -```bash -cd admin-dashboard && npm run build -``` - -Expected: successful Vite production build, no TypeScript errors. The build asserts the SPA bundles cleanly. - -- [ ] **Step 5: Commit all frontend changes together** - -```bash -cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template -git add admin-dashboard/src/features/apps/ -git commit -m "admin-dashboard: replace apps toggle stub with composio + native split" -``` - ---- - -## Task 10: README + `.env.example` - -**Files:** -- Modify: `admin-dashboard/README.md` -- Create/Modify: `admin-dashboard/.env.example` - -- [ ] **Step 1: Add `VITE_INSFORGE_OSS_URL` to `.env.example`** - -Read the file first: - -```bash -ls -a admin-dashboard | grep -i env -``` - -If `.env.example` exists, append: - -``` -# OSS dashboard origin for native-integration deep links (Stripe / OpenRouter). -# Defaults to https://app.insforge.dev when unset. -VITE_INSFORGE_OSS_URL=https://app.insforge.dev -``` - -If it doesn't exist, create it with the three lines above as the entire contents (per memory: dotfiles can be invisible to bare `ls` — `ls -a` was used above to confirm). - -- [ ] **Step 2: Rewrite the "Wire a real integration" section of README.md** - -Find the existing "Wire a real integration" paragraph (referenced in the original `use-toggle-app.ts` comment at line ~149). Replace that paragraph and surrounding instructions with this new section: - -````markdown -## Connecting third-party apps - -The `/apps` page lists every integration available to a workspace. Two kinds: - -- **InsForge-native** (Stripe, OpenRouter) — configured in the InsForge dashboard. The card opens the corresponding dashboard page in a new tab. -- **Composio-backed** (GitHub, Notion, Slack, Discord, Figma, Linear, Vercel) — connected per workspace via Composio's hosted OAuth. - -### One-time Composio setup - -1. Sign up at [composio.dev](https://composio.dev) and create an API key in **Settings → API Keys**. -2. For each of the 7 toolkits (`github`, `notion`, `slack`, `discord`, `figma`, `linear`, `vercel`), open the **Toolkits** page, click the toolkit, then **Create Auth Config** with OAuth 2.0. Copy the resulting `auth_config_id` (`ac_…`). -3. Store every value in InsForge secrets: - - ```bash - npx @insforge/cli secrets add COMPOSIO_API_KEY - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_GITHUB ac_xxx - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_NOTION ac_xxx - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_SLACK ac_xxx - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_DISCORD ac_xxx - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_FIGMA ac_xxx - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_LINEAR ac_xxx - npx @insforge/cli secrets add COMPOSIO_AUTH_CONFIG_VERCEL ac_xxx - ``` - -4. Deploy the three edge functions: - - ```bash - npx @insforge/cli functions deploy apps-connect --file ./functions/apps-connect.ts - npx @insforge/cli functions deploy apps-poll --file ./functions/apps-poll.ts - npx @insforge/cli functions deploy apps-disconnect --file ./functions/apps-disconnect.ts - ``` - -Composio routes its OAuth callback to its own domain — you do **not** need to add any URLs to `insforge.toml`. You do need to allow popups in your browser; the connect flow opens one synchronously when the user clicks "Connect". -```` - -- [ ] **Step 3: Commit** - -```bash -cd /Users/carmen/.config/superpowers/worktrees/admin-dashboard-template -git add admin-dashboard/README.md admin-dashboard/.env.example -git commit -m "admin-dashboard: document composio apps setup" -``` - ---- - -## Task 11: End-to-end smoke verification - -This is the verification gate that replaces unit tests. Driven manually in a browser. **Do not skip** — typecheck/lint/build do not cover the runtime OAuth flow. - -- [ ] **Step 1: Prerequisites** - -Confirm the following are true before starting: - -```bash -cd admin-dashboard -npx @insforge/cli secrets list | grep COMPOSIO # should list COMPOSIO_API_KEY + 7 AUTH_CONFIG entries -npx @insforge/cli functions list # apps-connect, apps-poll, apps-disconnect all 'active' -npx @insforge/cli db query "select slug, integration_kind from public.apps_catalog order by display_order" -# expected: 9 rows, no zapier -``` - -If any check fails, stop and fix the corresponding task before continuing. - -- [ ] **Step 2: Run the dev server** - -```bash -cd admin-dashboard && npm run dev -``` - -- [ ] **Step 3: Drive the Slack flow** - -1. Open the printed URL, sign in (or sign up), create a workspace if you don't have one. -2. Navigate to `/apps`. Expected: 9 cards. Stripe + OpenRouter show **Configure** buttons; the other 7 show **Connect** buttons. No Zapier card. -3. Click **Connect** on Slack. A popup opens to a `slack.com` URL via `backend.composio.dev`. -4. Authorize. Popup closes (or returns to a Composio confirmation page that you can close). -5. Within ~3 seconds the Slack card re-renders to show the **Connected** badge and `as `, plus a **Disconnect** button. -6. Refresh the page — state persists (`as ...` still shown). - -- [ ] **Step 4: Verify the DB row** - -```bash -npx @insforge/cli db query "select app_slug, status, config_json from public.app_connections where app_slug='slack'" -``` - -Expected: one row with `status=connected` and `config_json` containing `kind: composio`, a `connected_account_id`, and a non-null `account_label`. - -- [ ] **Step 5: Verify Composio dashboard** - -Open the Composio dashboard → Connected Accounts. Expected: a new ACTIVE row for Slack scoped to the workspace UUID (passed as `user_id`). - -- [ ] **Step 6: Disconnect** - -Click **Disconnect** on the Slack card. Expected: card returns to **Connect** state. - -```bash -npx @insforge/cli db query "select count(*) from public.app_connections where app_slug='slack'" -``` - -Expected: `0`. The Composio dashboard row should also be gone (or marked deleted depending on Composio's UI). - -- [ ] **Step 7: Spot-check a second toolkit** - -Repeat steps 3-4 for **GitHub** to confirm the code path is toolkit-agnostic. - -- [ ] **Step 8: Spot-check the native deep-link** - -Click **Configure** on Stripe. Expected: a new tab opens to `/payments`. - -- [ ] **Step 9: Final summary** - -If steps 3-8 all pass, the integration is functionally complete. If any step fails, identify the failing task above and fix it. No commit for this task (no code changes). - ---- - -## Self-Review Notes - -**Spec coverage:** Tasks 1-10 implement spec sections 4 (schema), 5 (edge functions), 6 (frontend), 7 (Composio setup), and 8 (env vars). Spec section 9 gotchas are baked in: (1) workspace_id as Composio user_id — Task 2 step 1; (2) polling cadence — Task 6 step 1 (1.5 s × 80 = 120 s deadline); (3) popup blockers — Task 6 step 1 opens popup synchronously before await; (4) `VITE_INSFORGE_OSS_URL` for self-hosted — Task 10. Spec section 10 demo path == Task 11. - -**Open contract risks** (called out at point-of-use): Composio v3 REST field naming (Tasks 2-3) and `insforge.functions.invoke` GET-with-body support (Task 6). Both will be caught immediately by Task 11 step 3 if wrong. - -**No placeholders.** Every code block is a complete, copy-pasteable artifact. Where this plan defers to "read the file first" (Task 9 step 1), it's because the current file's structure isn't shown in the spec and we want to minimize what gets rewritten. diff --git a/admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md b/admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md deleted file mode 100644 index f09a510..0000000 --- a/admin-dashboard/docs/superpowers/specs/2026-05-26-composio-apps-integration.md +++ /dev/null @@ -1,470 +0,0 @@ -# Composio Integration for Apps Page — Design Spec - -**Date:** 2026-05-26 -**Template:** `admin-dashboard` -**Author:** InsForge - -## 1. Goal - -Replace the stub toggle in `src/features/apps/use-toggle-app.ts` with real per-workspace OAuth flows for 7 SaaS integrations, brokered by [Composio](https://composio.dev) Connected Accounts. After this change, clicking "Connect" on a Slack/Notion/GitHub/Discord/Figma/Linear/Vercel card will: - -1. Open the provider's real OAuth consent screen (hosted by Composio). -2. Persist the resulting Composio `connected_account_id` into `app_connections.config_json`. -3. Show the connected account label ("Connected as `@alice`") and a Disconnect button on the card. - -Stripe and OpenRouter remain InsForge-native (their cards deep-link to the OSS dashboard's existing settings pages). The `zapier` card is removed — it overlaps semantically with Composio itself. - -## 2. Scope - -### In scope - -| Service | Integration | -|---------|-------------| -| GitHub | Composio (`github`) | -| Notion | Composio (`notion`) | -| Slack | Composio (`slack`) | -| Discord | Composio (`discord`) | -| Figma | Composio (`figma`) | -| Linear | Composio (`linear`) | -| Vercel | Composio (`vercel`) | -| Stripe | InsForge-native — deep link to OSS dashboard `/payments` | -| OpenRouter | InsForge-native — deep link to OSS dashboard `/ai` | - -### Out of scope - -- **Zapier card** — removed from the seed list. -- **Acting on the connection** (posting to Slack, creating Notion pages, etc.) — this spec only covers connection management. A follow-up template iteration can add an "Automations" page that wires Composio actions to InsForge realtime triggers. -- **Per-user (end-user) connections** — connections are scoped to the workspace (admin connects once, all workspace members share the connection). - -## 3. Architecture - -### Why Composio - -Each of the 7 SaaS apps has its own OAuth dance, scopes, refresh-token policy, and API surface. Doing them individually means 7 × (OAuth client registration + token storage + refresh logic + API client). Composio handles all of that and exposes a unified Connected Accounts model: one API to initiate auth, one API to list/revoke connections, one API to execute actions. - -### Flow - -``` -[User] [SPA] [InsForge edge fn] [Composio] - | | | | - | click "Connect Slack" | | | - |---------------------->| | | - | | POST /apps/slack/connect | | - | |--------------------------->| | - | | | initiateConnectedAccount() | - | | |--------------------------->| - | | | { redirectUrl, id } | - | | |<---------------------------| - | | { redirectUrl, request_id }| | - | |<---------------------------| | - | | | | - | popup -> Slack OAuth | | | - |---------------------------------------------------------------------------------| - | | | | - | Slack callback -> Composio callback URL | | - |---------------------------------------------------------------------------------| - | | | | - | | poll: GET /apps/slack | | - | | /poll?request_id=... | | - | |--------------------------->| | - | | | getConnectedAccount(req_id)| - | | |--------------------------->| - | | | { status: ACTIVE, ... } | - | | |<---------------------------| - | | | upsert app_connections | - | | | .config_json with | - | | | { connected_account_id, | - | | | account_label } | - | | { connected: true, label }| | - | |<---------------------------| | - | card re-renders | | | -``` - -Composio's OAuth callback goes to `https://backend.composio.dev/api/v3/...` — **not** the template's domain. So we do not need to add any callback URLs to `insforge.toml`'s `allowed_redirect_urls`. The SPA polls our edge function (which polls Composio) until the connection becomes `ACTIVE` or times out. - -## 4. Data Model Changes - -All additive — no destructive migration. - -### `apps_catalog` — add `integration_kind` column - -```sql -alter table public.apps_catalog - add column if not exists integration_kind text not null default 'composio' - check (integration_kind in ('composio', 'insforge_native')); - -alter table public.apps_catalog - add column if not exists composio_toolkit_slug text; -``` - -- `integration_kind` — drives client-side branching (Composio OAuth vs deep link). -- `composio_toolkit_slug` — Composio's toolkit identifier (e.g. `slack`, `github`). Usually matches our `slug`; kept separate so we can rename our slug without breaking Composio mapping. - -### `apps_catalog` — re-seed - -```sql --- Replace the existing single INSERT block (db_init.sql:610-621) with this. -insert into public.apps_catalog (slug, name, description, icon_url, integration_kind, composio_toolkit_slug, display_order) values - ('stripe', 'Stripe', 'Accept payments, manage subscriptions, and view revenue.', 'https://cdn.simpleicons.org/stripe', 'insforge_native', null, 1), - ('openrouter', 'OpenRouter', 'Unified API for 100+ AI models, with usage analytics.', 'https://cdn.simpleicons.org/openrouter', 'insforge_native', null, 2), - ('github', 'GitHub', 'Sync issues, manage releases, and trigger workflows.', 'https://cdn.simpleicons.org/github', 'composio', 'github', 3), - ('notion', 'Notion', 'Embed docs, capture notes, and link knowledge bases.', 'https://cdn.simpleicons.org/notion', 'composio', 'notion', 4), - ('slack', 'Slack', 'Get notifications and respond to events without leaving Slack.','https://cdn.simpleicons.org/slack', 'composio', 'slack', 5), - ('discord', 'Discord', 'Bridge community channels into your workspace.', 'https://cdn.simpleicons.org/discord', 'composio', 'discord', 6), - ('figma', 'Figma', 'Link design files and surface comments inline.', 'https://cdn.simpleicons.org/figma', 'composio', 'figma', 7), - ('linear', 'Linear', 'Track issues, ship faster, and keep tasks in sync.', 'https://cdn.simpleicons.org/linear', 'composio', 'linear', 8), - ('vercel', 'Vercel', 'Tail deployments and surface preview URLs.', 'https://cdn.simpleicons.org/vercel', 'composio', 'vercel', 9) -on conflict (slug) do update set - integration_kind = excluded.integration_kind, - composio_toolkit_slug = excluded.composio_toolkit_slug; - --- Drop Zapier from any existing dev database. -delete from public.apps_catalog where slug = 'zapier'; -``` - -The pre-existing `oauth_provider` column on `apps_catalog` becomes dead — leave it for now to avoid a destructive migration; the next major schema rev can drop it. - -### `app_connections.config_json` shape (no DDL change) - -The column is already `jsonb not null default '{}'::jsonb`. Composio-backed connections will store: - -```json -{ - "kind": "composio", - "connected_account_id": "ca_AbC123...", - "account_label": "@alice in acme-eng", - "connected_at_provider": "2026-05-26T14:22:10Z" -} -``` - -InsForge-native connections continue to leave `config_json` empty `{}` — we just key off the row's existence + `apps_catalog.integration_kind = 'insforge_native'` to know what to render. - -## 5. Backend — InsForge Edge Functions - -Two new functions, deployed via `npx @insforge/cli functions deploy`. The template root will gain a `functions/` directory mirroring the [chatbot template's pattern](../../../chatbot/functions/). - -### `functions/apps-connect/index.ts` - -```typescript -// POST /apps-connect -// Body: { app_slug: string, workspace_id: string } -// Returns: { redirect_url: string, request_id: string } - -import { ComposioClient } from '@composio/core' -import { getRequestingUser, getWorkspaceMembership, json, err } from './_shared.ts' - -const composio = new ComposioClient({ apiKey: Deno.env.get('COMPOSIO_API_KEY')! }) - -export default async function handler(req: Request): Promise { - if (req.method !== 'POST') return err(405, 'method_not_allowed') - - const user = await getRequestingUser(req) - if (!user) return err(401, 'unauthorized') - - const { app_slug, workspace_id } = await req.json() - if (!app_slug || !workspace_id) return err(400, 'missing_fields') - - const member = await getWorkspaceMembership(user.id, workspace_id) - if (!member) return err(403, 'not_a_workspace_member') - - // Look up the toolkit slug from apps_catalog. - const { data: appRow } = await member.db - .from('apps_catalog') - .select('integration_kind, composio_toolkit_slug') - .eq('slug', app_slug) - .single() - - if (appRow?.integration_kind !== 'composio' || !appRow.composio_toolkit_slug) { - return err(400, 'not_a_composio_app') - } - - // Composio Connected Accounts: initiate. - // The auth_config_id is shared per toolkit, registered once in Composio dashboard - // and stored in env as COMPOSIO_AUTH_CONFIG_. - const authConfigId = Deno.env.get( - `COMPOSIO_AUTH_CONFIG_${appRow.composio_toolkit_slug.toUpperCase()}`, - ) - if (!authConfigId) return err(500, 'auth_config_not_provisioned') - - const init = await composio.connectedAccounts.initiate({ - userId: workspace_id, // <-- one Composio "user" per InsForge workspace - authConfigId, - }) - - return json({ - redirect_url: init.redirectUrl, - request_id: init.id, - }) -} -``` - -### `functions/apps-poll/index.ts` - -```typescript -// GET /apps-poll?request_id=...&app_slug=...&workspace_id=... -// Returns: { status: 'pending' | 'connected' | 'failed', account_label?: string } - -import { ComposioClient } from '@composio/core' -import { getRequestingUser, getWorkspaceMembership, json, err } from './_shared.ts' - -const composio = new ComposioClient({ apiKey: Deno.env.get('COMPOSIO_API_KEY')! }) - -export default async function handler(req: Request): Promise { - if (req.method !== 'GET') return err(405, 'method_not_allowed') - - const user = await getRequestingUser(req) - if (!user) return err(401, 'unauthorized') - - const url = new URL(req.url) - const request_id = url.searchParams.get('request_id') - const app_slug = url.searchParams.get('app_slug') - const workspace_id = url.searchParams.get('workspace_id') - if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') - - const member = await getWorkspaceMembership(user.id, workspace_id) - if (!member) return err(403, 'not_a_workspace_member') - - const account = await composio.connectedAccounts.get(request_id) - - if (account.status === 'INITIATED') { - return json({ status: 'pending' }) - } - if (account.status !== 'ACTIVE') { - return json({ status: 'failed' }) - } - - // Persist with the user's RLS context so the insert passes - // app_connections_insert_member. - const { error } = await member.db - .from('app_connections') - .upsert( - { - workspace_id, - app_slug, - status: 'connected', - connected_by: user.id, - connected_at: new Date().toISOString(), - config_json: { - kind: 'composio', - connected_account_id: account.id, - account_label: account.data?.account_label ?? account.data?.user_email ?? null, - connected_at_provider: account.createdAt, - }, - }, - { onConflict: 'workspace_id,app_slug' }, - ) - if (error) return err(500, error.message) - - return json({ - status: 'connected', - account_label: account.data?.account_label ?? null, - }) -} -``` - -### `functions/apps-disconnect/index.ts` - -```typescript -// POST /apps-disconnect -// Body: { app_slug: string, workspace_id: string } -// Returns: { ok: true } - -import { ComposioClient } from '@composio/core' -import { getRequestingUser, getWorkspaceMembership, json, err } from './_shared.ts' - -const composio = new ComposioClient({ apiKey: Deno.env.get('COMPOSIO_API_KEY')! }) - -export default async function handler(req: Request): Promise { - if (req.method !== 'POST') return err(405, 'method_not_allowed') - - const user = await getRequestingUser(req) - if (!user) return err(401, 'unauthorized') - - const { app_slug, workspace_id } = await req.json() - if (!app_slug || !workspace_id) return err(400, 'missing_fields') - - const member = await getWorkspaceMembership(user.id, workspace_id) - if (!member) return err(403, 'not_a_workspace_member') - - // Look up the existing connection to get the Composio account id. - const { data: existing } = await member.db - .from('app_connections') - .select('config_json') - .eq('workspace_id', workspace_id) - .eq('app_slug', app_slug) - .single() - - const composioId = existing?.config_json?.connected_account_id - if (composioId) { - await composio.connectedAccounts.delete(composioId).catch(() => { - // Best-effort: if Composio 404s the connection is already gone. - }) - } - - const { error } = await member.db - .from('app_connections') - .delete() - .eq('workspace_id', workspace_id) - .eq('app_slug', app_slug) - if (error) return err(500, error.message) - - return json({ ok: true }) -} -``` - -### `functions/_shared.ts` - -Small helpers — `getRequestingUser` (verifies the InsForge JWT from `Authorization: Bearer`), `getWorkspaceMembership` (returns `{ db: insforgeServerClient }` scoped to that user, or `null` if not a member), and JSON response shorthands. Mirror the chatbot template's `functions/_shared.ts`. - -## 6. Frontend Changes - -### `src/features/apps/use-apps.ts` — surface `integration_kind` and `account_label` - -Extend the row shape so the UI can branch: - -```typescript -export type AppWithConnection = { - slug: string - name: string - description: string - icon_url: string | null - display_order: number - integration_kind: 'composio' | 'insforge_native' - oss_dashboard_path: string | null // for insforge_native deep link - connected: boolean - account_label: string | null -} -``` - -Update the catalog select to pull `integration_kind` and `composio_toolkit_slug`, and the connection select to pull `config_json`. Compute `oss_dashboard_path` client-side from a hard map: - -```typescript -const OSS_DEEP_LINKS: Record = { - stripe: '/payments', - openrouter: '/ai', -} -``` - -(The OSS dashboard origin is read from `import.meta.env.VITE_INSFORGE_OSS_URL`.) - -### `src/features/apps/use-toggle-app.ts` — replace stub with branched flow - -Becomes a thin router that dispatches to one of three handlers: - -```typescript -export function useConnectApp(workspaceId: string | undefined) { - // Branches on app.integration_kind: - // - composio: POST /apps-connect, open popup to redirect_url, poll until ACTIVE - // - insforge_native: window.open(`${OSS_URL}${oss_dashboard_path}`, '_blank') -} - -export function useDisconnectApp(workspaceId: string | undefined) { - // POST /apps-disconnect -} -``` - -Composio connect flow on the client: - -```typescript -async function connectComposio(app: AppWithConnection, workspaceId: string) { - const { redirect_url, request_id } = await callFunction('apps-connect', { - app_slug: app.slug, - workspace_id: workspaceId, - }) - - const popup = window.open(redirect_url, 'composio-oauth', 'width=600,height=720') - if (!popup) throw new Error('Popup blocked') - - // Poll every 1.5s for up to 2 minutes. - const deadline = Date.now() + 120_000 - while (Date.now() < deadline) { - if (popup.closed) { - // user closed without finishing — do a final poll to be sure - } - await sleep(1500) - const r = await callFunction('apps-poll', { - request_id, app_slug: app.slug, workspace_id: workspaceId, - }, { method: 'GET' }) - if (r.status === 'connected') { - popup.close() - return r - } - if (r.status === 'failed') { - popup.close() - throw new Error('Authorization failed') - } - } - popup.close() - throw new Error('Authorization timed out') -} -``` - -### `src/features/apps/apps-grid.tsx` — replace Switch with kind-aware control - -The current `` is the wrong affordance for OAuth-backed connections (you cannot meaningfully "toggle" an OAuth grant atomically). Replace with: - -| Card state | Composio app | InsForge-native app | -|------------|--------------|---------------------| -| not connected | `` → opens popup | `` → opens OSS link in new tab | -| pending | `` with spinner | n/a | -| connected | `Connected as {account_label}` + `` | `Configured` + the same dashboard link | - -Drop the `Switch` import. - -## 7. One-Time Composio Dashboard Setup - -For each of the 7 toolkits, the template author needs to do this **once** in the Composio dashboard (https://app.composio.dev) and capture the resulting `auth_config_id`: - -1. Go to **Toolkits** → pick e.g. **Slack**. -2. Click **Create Auth Config**. -3. Choose **OAuth 2.0** (Composio handles the OAuth client registration with the provider in dev mode; for production each toolkit needs its own provider-side OAuth app, configured per the Composio docs page for that toolkit). -4. Copy the `auth_config_id` (looks like `ac_AbC123…`). - -The resulting 7 IDs go into the project's secrets: - -```bash -npx @insforge/cli secrets set COMPOSIO_API_KEY '' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_GITHUB 'ac_...' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_NOTION 'ac_...' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_SLACK 'ac_...' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_DISCORD 'ac_...' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_FIGMA 'ac_...' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_LINEAR 'ac_...' -npx @insforge/cli secrets set COMPOSIO_AUTH_CONFIG_VERCEL 'ac_...' -``` - -The README's "Wire a real integration" section will be rewritten as a checklist pointing at this setup. A `npm run setup:composio` script can echo the above with the project's actual secret names for convenience. - -## 8. Environment Variables - -| Var | Where | Purpose | -|-----|-------|---------| -| `COMPOSIO_API_KEY` | InsForge secrets (edge fns) | Composio SDK auth | -| `COMPOSIO_AUTH_CONFIG_` × 7 | InsForge secrets (edge fns) | per-toolkit auth config id | -| `VITE_INSFORGE_OSS_URL` | `.env.local` (SPA) | OSS dashboard origin for native deep links; defaults to `https://app.insforge.dev` | - -## 9. Open Questions / Gotchas - -1. **Workspace-as-Composio-user.** We pass `userId: workspace_id` to Composio. This means the Composio dashboard's "user" axis = our workspace axis, which is what we want (workspace-scoped connections, not per-end-user). If a future iteration wants per-end-user connections, switch to `userId: user.id`. - -2. **Composio rate limits on poll.** Composio's free tier limits API calls. The 1.5 s polling interval = ~80 calls per 2-minute OAuth window per pending connection — fine for one at a time, but if we ever support bulk-connect, switch to webhooks (Composio supports `connection.activated` webhooks). - -3. **Popup blockers.** Modern browsers block popups not opened in a direct user-gesture handler. Confirm the popup opens synchronously inside the button's `onClick`, before the `await callFunction('apps-connect', ...)` resolves — otherwise it gets blocked. The actual `redirect_url` is then assigned to `popup.location` once the fetch returns. - -4. **Stripe/OpenRouter deep links assume the user is in cloud, not OSS-only.** A user running this template against a self-hosted InsForge OSS instance will have a different dashboard URL. The `VITE_INSFORGE_OSS_URL` env var covers this; the README needs to call it out. - -5. **No Composio CLI dependency at runtime.** The skill `composio-cli` is for human operators; the template uses the Composio JavaScript SDK (`@composio/core`) directly inside edge functions. - -## 10. Demo Path (verification at the end) - -1. `cd admin-dashboard && npm install && npm run setup` -2. Set secrets per §7. -3. `npx @insforge/cli functions deploy` -4. `npm run dev`, sign in, create a workspace. -5. Go to `/apps`, click **Connect** on the Slack card. -6. Popup opens to Slack OAuth → authorize → popup closes. -7. Card now shows **Connected as `@alice in acme-eng`** with a Disconnect button. -8. Refresh — state persists. -9. Click **Disconnect** — card returns to **Connect** state, and the connection is gone from the Composio dashboard. - -If all 9 steps pass for Slack, repeat for one more (e.g. GitHub) to confirm the toolkit-agnostic code path. From e5d7e77b6dfcbe8296446658024bc39d541f52bf Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 14:24:29 -0700 Subject: [PATCH 14/15] admin-dashboard: address cubic review feedback --- admin-dashboard/functions/apps-disconnect.ts | 17 +++++++++--- admin-dashboard/functions/apps-poll.ts | 7 +++++ .../functions/apps-slack-list-channels.ts | 4 +-- .../functions/apps-slack-send-task.ts | 16 +++++++---- ...260526150000_composio-apps-integration.sql | 5 ++++ .../src/features/apps/apps-page.tsx | 27 ++++++++++++++++--- .../src/features/apps/use-apps-config.ts | 5 ++-- 7 files changed, 64 insertions(+), 17 deletions(-) diff --git a/admin-dashboard/functions/apps-disconnect.ts b/admin-dashboard/functions/apps-disconnect.ts index 6fbb9c0..430a4f4 100644 --- a/admin-dashboard/functions/apps-disconnect.ts +++ b/admin-dashboard/functions/apps-disconnect.ts @@ -55,10 +55,19 @@ export default async function handler(req: Request): Promise { if (composioId) { const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') if (composioApiKey) { - await fetch( - `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(composioId)}`, - { method: 'DELETE', headers: { 'x-api-key': composioApiKey } }, - ).catch(() => {}) + try { + const revokeRes = await fetch( + `https://backend.composio.dev/api/v3/connected_accounts/${encodeURIComponent(composioId)}`, + { method: 'DELETE', headers: { 'x-api-key': composioApiKey } }, + ) + if (!revokeRes.ok && revokeRes.status !== 404) { + console.warn( + `composio revoke failed: ${composioId} status=${revokeRes.status} body=${await revokeRes.text()}`, + ) + } + } catch (e) { + console.warn(`composio revoke threw: ${composioId} err=${(e as Error).message}`) + } } } diff --git a/admin-dashboard/functions/apps-poll.ts b/admin-dashboard/functions/apps-poll.ts index 71960a5..961d077 100644 --- a/admin-dashboard/functions/apps-poll.ts +++ b/admin-dashboard/functions/apps-poll.ts @@ -42,6 +42,13 @@ export default async function handler(req: Request): Promise { const { request_id, app_slug, workspace_id } = body if (!request_id || !app_slug || !workspace_id) return err(400, 'missing_fields') + const { data: appRow, error: appErr } = await client.database + .from('apps_catalog') + .select('slug') + .eq('slug', app_slug) + .single() + if (appErr || !appRow) return err(404, 'app_not_found', appErr?.message) + const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') if (!composioApiKey) return err(500, 'composio_api_key_missing') diff --git a/admin-dashboard/functions/apps-slack-list-channels.ts b/admin-dashboard/functions/apps-slack-list-channels.ts index 06851e3..e790532 100644 --- a/admin-dashboard/functions/apps-slack-list-channels.ts +++ b/admin-dashboard/functions/apps-slack-list-channels.ts @@ -54,7 +54,7 @@ export default async function handler(req: Request): Promise { const connectedAccountId = (connRow.config_json as { connected_account_id?: string }) ?.connected_account_id - if (!connectedAccountId) return err(409, 'slack_not_connected') + if (!connectedAccountId) return err(404, 'slack_not_connected') const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') if (!composioApiKey) return err(500, 'composio_api_key_missing') @@ -67,7 +67,7 @@ export default async function handler(req: Request): Promise { body: JSON.stringify({ user_id: workspace_id, connected_account_id: connectedAccountId, - arguments: { limit: 200, exclude_archived: true, types: 'public_channel,private_channel' }, + arguments: { limit: 1000, exclude_archived: true, types: 'public_channel,private_channel' }, }), }, ) diff --git a/admin-dashboard/functions/apps-slack-send-task.ts b/admin-dashboard/functions/apps-slack-send-task.ts index 3549998..db5ed02 100644 --- a/admin-dashboard/functions/apps-slack-send-task.ts +++ b/admin-dashboard/functions/apps-slack-send-task.ts @@ -29,6 +29,12 @@ const PRIORITY_LABEL: Record = { high: 'High', } +// Slack mrkdwn treats &, <, > as control chars (mentions, links). Escape any +// user-controlled text so a task titled "" can't blast a channel. +function escapeSlack(text: string) { + return text.replace(/&/g, '&').replace(//g, '>') +} + function formatMessage(task: { title: string description: string | null @@ -37,12 +43,12 @@ function formatMessage(task: { due_date: string | null }) { const lines: string[] = [] - lines.push(`*${task.title}*`) + lines.push(`*${escapeSlack(task.title)}*`) lines.push( `${STATUS_LABEL[task.status] ?? task.status} · ${PRIORITY_LABEL[task.priority] ?? task.priority}` + - (task.due_date ? ` · due ${task.due_date}` : ''), + (task.due_date ? ` · due ${escapeSlack(task.due_date)}` : ''), ) - if (task.description) lines.push('', task.description.slice(0, 500)) + if (task.description) lines.push('', escapeSlack(task.description.slice(0, 500))) return lines.join('\n') } @@ -86,12 +92,12 @@ export default async function handler(req: Request): Promise { .eq('workspace_id', workspace_id) .eq('app_slug', 'slack') .single() - if (connErr || !connRow) return err(409, 'slack_not_connected') + if (connErr || !connRow) return err(404, 'slack_not_connected') if (connRow.status !== 'connected') return err(409, 'slack_not_connected') const connectedAccountId = (connRow.config_json as { connected_account_id?: string }) ?.connected_account_id - if (!connectedAccountId) return err(409, 'slack_not_connected') + if (!connectedAccountId) return err(404, 'slack_not_connected') const composioApiKey = Deno.env.get('COMPOSIO_API_KEY') if (!composioApiKey) return err(500, 'composio_api_key_missing') diff --git a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql index eddb7f0..63f8f04 100644 --- a/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql +++ b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql @@ -16,4 +16,9 @@ on conflict (slug) do update set description = excluded.description, display_order = excluded.display_order; +-- Defensive: remove any connections for the slugs we are about to drop so the +-- FK cascade has nothing left to silently destroy. Stripe/OpenRouter never +-- supported composio OAuth, so this should be a no-op on fresh installs; Zapier +-- was a placeholder catalog entry that didn't ship a working flow. +delete from public.app_connections where app_slug in ('zapier', 'stripe', 'openrouter'); delete from public.apps_catalog where slug in ('zapier', 'stripe', 'openrouter'); diff --git a/admin-dashboard/src/features/apps/apps-page.tsx b/admin-dashboard/src/features/apps/apps-page.tsx index 670480f..a16d9cd 100644 --- a/admin-dashboard/src/features/apps/apps-page.tsx +++ b/admin-dashboard/src/features/apps/apps-page.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { Info } from 'lucide-react' +import { AlertTriangle, Info } from 'lucide-react' import { useActiveWorkspace } from '@/features/dashboard/use-active-workspace' import { useApps } from './use-apps' import { useAppsConfig } from './use-apps-config' @@ -13,7 +13,7 @@ const COMPOSIO_SETUP_URL = export function AppsPage() { const { workspace } = useActiveWorkspace() const { data: apps = [], isLoading } = useApps(workspace?.id) - const { data: appsConfig, isLoading: isConfigLoading } = useAppsConfig() + const { data: appsConfig, isLoading: isConfigLoading, isError: isConfigError, refetch: refetchConfig } = useAppsConfig() const connect = useConnectApp(workspace?.id) const disconnect = useDisconnectApp(workspace?.id) @@ -27,7 +27,7 @@ export function AppsPage() { }, [apps, appsConfig]) const showSetupBanner = - !isConfigLoading && apps.length > 0 && !appsConfig?.composio_enabled + !isConfigLoading && !isConfigError && apps.length > 0 && !appsConfig?.composio_enabled const isConnectPending = (slug: string) => connect.isPending && connect.variables?.app.slug === slug @@ -42,6 +42,27 @@ export function AppsPage() { Connect integrations to extend {workspace?.name ?? 'your workspace'}.

+ {isConfigError && ( +
+ +
+

Couldn't load app integration status

+

+ Connect/disconnect actions may not work until this resolves. +

+ +
+
+ )} {showSetupBanner && (
| null return { composio_enabled: !!cfg?.composio_enabled, @@ -27,5 +25,6 @@ export function useAppsConfig() { } }, staleTime: 5 * 60 * 1000, + retry: 2, }) } From d8f4c7b575907439935d82a7e89e6612aa2331d6 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 14:35:12 -0700 Subject: [PATCH 15/15] admin-dashboard: restore capitalized feature labels, add composio integrations and outbound actions --- registry.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry.json b/registry.json index 9b230c8..ef54b55 100644 --- a/registry.json +++ b/registry.json @@ -70,7 +70,7 @@ "description": "Multi-user SaaS admin with workspace invites, sortable tables, real-time chat, charts, and a Composio-powered apps grid that pushes tasks to a chosen channel with two clicks. Vite + React + InsForge auth, database, RLS, storage, and realtime.", "category": "admin", "framework": "react", - "features": ["auth", "database", "storage", "realtime", "composio", "outbound-actions"], + "features": ["TanStack Table", "Recharts", "Real-time Chat", "Auth", "Composio Integrations", "Outbound Actions"], "tags": ["admin", "dashboard", "shadcn", "tanstack-table", "workspace", "saas", "composio", "oauth"], "cover": "assets/covers/admin-dashboard.png", "demo_url": "https://admindashboard.insforge.site",