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/README.md b/admin-dashboard/README.md index 36df308..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 — 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 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. --- @@ -142,11 +142,56 @@ admin-dashboard/ --- +## Connecting third-party apps + +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 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. + +### 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 six edge functions (already shipped under `functions/`): + + ```bash + 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**. + +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) 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/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/functions/apps-connect.ts b/admin-dashboard/functions/apps-connect.ts new file mode 100644 index 0000000..de11879 --- /dev/null +++ b/admin-dashboard/functions/apps-connect.ts @@ -0,0 +1,91 @@ +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 linkRes = await fetch( + 'https://backend.composio.dev/api/v3/connected_accounts/link', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': composioApiKey, + }, + body: JSON.stringify({ + auth_config_id: authConfigId, + user_id: workspace_id, + }), + }, + ) + + if (!linkRes.ok) { + const text = await linkRes.text() + return err(502, 'composio_link_failed', text) + } + const link = (await linkRes.json()) as { + connected_account_id: string + redirect_url: string + } + + return json(200, { + request_id: link.connected_account_id, + redirect_url: link.redirect_url, + }) +} diff --git a/admin-dashboard/functions/apps-disconnect.ts b/admin-dashboard/functions/apps-disconnect.ts new file mode 100644 index 0000000..430a4f4 --- /dev/null +++ b/admin-dashboard/functions/apps-disconnect.ts @@ -0,0 +1,82 @@ +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) { + 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}`) + } + } + } + + 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 }) +} diff --git a/admin-dashboard/functions/apps-poll.ts b/admin-dashboard/functions/apps-poll.ts new file mode 100644 index 0000000..961d077 --- /dev/null +++ b/admin-dashboard/functions/apps-poll.ts @@ -0,0 +1,112 @@ +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: { 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 { 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') + + 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 + word_id?: string + data?: Record + created_at?: string + } + + const FAILED_STATES = ['FAILED', 'EXPIRED', 'INACTIVE', 'DELETED'] + if (account.status !== 'ACTIVE') { + 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 + .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 }) +} 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..e790532 --- /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(404, '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: 1000, 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..db5ed02 --- /dev/null +++ b/admin-dashboard/functions/apps-slack-send-task.ts @@ -0,0 +1,129 @@ +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', +} + +// 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 + status: string + priority: string + due_date: string | null +}) { + const lines: string[] = [] + lines.push(`*${escapeSlack(task.title)}*`) + lines.push( + `${STATUS_LABEL[task.status] ?? task.status} · ${PRIORITY_LABEL[task.priority] ?? task.priority}` + + (task.due_date ? ` · due ${escapeSlack(task.due_date)}` : ''), + ) + if (task.description) lines.push('', escapeSlack(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(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(404, '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/migrations/20260526150000_composio-apps-integration.sql b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql new file mode 100644 index 0000000..63f8f04 --- /dev/null +++ b/admin-dashboard/migrations/20260526150000_composio-apps-integration.sql @@ -0,0 +1,24 @@ +-- 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, 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 + composio_toolkit_slug = excluded.composio_toolkit_slug, + 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/migrations/db_init.sql b/admin-dashboard/migrations/db_init.sql index d02bc3e..9b13aff 100644 --- a/admin-dashboard/migrations/db_init.sql +++ b/admin-dashboard/migrations/db_init.sql @@ -84,6 +84,7 @@ create table if not exists public.apps_catalog ( description text not null, icon_url text, oauth_provider text, + composio_toolkit_slug text, display_order integer not null default 0 ); @@ -607,15 +608,12 @@ 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, 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/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 }, 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/features/apps/apps-grid.tsx b/admin-dashboard/src/features/apps/apps-grid.tsx index 6dac1d0..f38e499 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 { 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,97 @@ 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) + + return ( + + {app.connected && ( + + Connected + + )} + +
+ {app.icon_url ? ( + {`${app.name} + ) : ( +
+ )} + {app.name} +
+ + {app.description} + + + + + {app.connected ? ( + <> + + {app.account_label ? `as ${app.account_label}` : 'Active integration'} + + + + ) : !app.is_available ? ( + <> + Coming soon + + ) : ( -
+ <> + 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..a16d9cd 100644 --- a/admin-dashboard/src/features/apps/apps-page.tsx +++ b/admin-dashboard/src/features/apps/apps-page.tsx @@ -1,34 +1,38 @@ -import { useState } from 'react' +import { useMemo } from 'react' +import { AlertTriangle, Info } from 'lucide-react' import { useActiveWorkspace } from '@/features/dashboard/use-active-workspace' import { useApps } from './use-apps' -import { useToggleApp } from './use-toggle-app' +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 toggle = useToggleApp(workspace?.id) - const [pendingSlugs, setPendingSlugs] = useState>(new Set()) + const { data: appsConfig, isLoading: isConfigLoading, isError: isConfigError, refetch: refetchConfig } = useAppsConfig() + 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 + const enrichedApps = useMemo(() => { + const toolkits = new Set(appsConfig?.configured_toolkits ?? []) + return apps.map((app) => { + const slug = app.composio_toolkit_slug + const available = !!appsConfig?.composio_enabled && !!slug && toolkits.has(slug) + return { ...app, is_available: available } }) - toggle.mutate( - { slug, connected: next }, - { - onSettled: () => { - setPendingSlugs((prev) => { - const s = new Set(prev) - s.delete(slug) - return s - }) - }, - }, - ) - } + }, [apps, appsConfig]) + + const showSetupBanner = + !isConfigLoading && !isConfigError && apps.length > 0 && !appsConfig?.composio_enabled + + const isConnectPending = (slug: string) => + connect.isPending && connect.variables?.app.slug === slug + const isDisconnectPending = (slug: string) => + disconnect.isPending && disconnect.variables?.app.slug === slug return (
@@ -38,11 +42,57 @@ 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 && ( +
+ +
+

Third-party integrations need Composio

+

+ Provision Composio secrets on this project to enable GitHub, Slack, Notion, + and the other OAuth apps. +

+ + View setup guide → + +
+
+ )} pendingSlugs.has(slug)} - onToggle={handleToggle} + apps={enrichedApps} + isLoading={isLoading || isConfigLoading} + isConnectPending={isConnectPending} + isDisconnectPending={isDisconnectPending} + onConnect={(app) => connect.mutate({ app })} + onDisconnect={(app) => disconnect.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..39fc05f --- /dev/null +++ b/admin-dashboard/src/features/apps/use-apps-config.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import { insforge } from '@/lib/insforge' + +export type AppsConfig = { + composio_enabled: boolean + configured_toolkits: string[] +} + +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) throw new Error(error.message ?? 'Failed to load apps config') + 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, + retry: 2, + }) +} diff --git a/admin-dashboard/src/features/apps/use-apps.ts b/admin-dashboard/src/features/apps/use-apps.ts index 3e10dae..7499110 100644 --- a/admin-dashboard/src/features/apps/use-apps.ts +++ b/admin-dashboard/src/features/apps/use-apps.ts @@ -6,10 +6,16 @@ export type App = { name: string description: string icon_url: string | null - oauth_provider: string | null + 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 +23,7 @@ export type AppConnection = { status: 'connected' | 'disconnected' connected_at: string connected_by: string + config_json: ConnectionConfig | null } export type AppWithConnection = { @@ -25,7 +32,10 @@ export type AppWithConnection = { description: string icon_url: string | null display_order: number + composio_toolkit_slug: string | null connected: boolean + account_label: string | null + is_available: boolean } export const appsKey = (workspaceId: string | undefined) => ['apps', workspaceId] as const @@ -38,11 +48,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, 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 +63,24 @@ 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, + composio_toolkit_slug: app.composio_toolkit_slug, + connected: !!conn, + account_label: conn?.config_json?.account_label ?? null, + 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 new file mode 100644 index 0000000..5b6db46 --- /dev/null +++ b/admin-dashboard/src/features/apps/use-connect-app.ts @@ -0,0 +1,84 @@ +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 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', { + method: 'POST', + 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: 'POST', + 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') + await connectComposio({ appSlug: app.slug, workspaceId }) + }, + onSuccess: (_data, { app }) => { + 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..adaddea --- /dev/null +++ b/admin-dashboard/src/features/apps/use-disconnect-app.ts @@ -0,0 +1,29 @@ +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', { + method: 'POST', + 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-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/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') - }, - }) -} 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} /> + + 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/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() {

diff --git a/registry.json b/registry.json index f67d519..ef54b55 100644 --- a/registry.json +++ b/registry.json @@ -66,12 +66,12 @@ }, { "slug": "admin-dashboard", - "name": "Admin Dashboard Starter", - "description": "The internal tool you'd otherwise spend sprint 1 building. Workspaces with invites, sortable data tables, real-time chat, charts, and a settings page — turnkey.", + "name": "Admin Dashboard", + "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": ["TanStack Table", "Recharts", "Real-time Chat", "Auth"], - "tags": ["admin", "dashboard", "shadcn", "tanstack-table", "workspace", "saas"], + "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", "author": "InsForge",