Unverified 提交 c4ee578c authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Multi supabase accounts (#2014)

Fixes #527 <!-- CURSOR_SUMMARY --> > [!NOTE] > Enables org-scoped Supabase connectivity across the app with per-organization tokens and org-aware project/branch/logs/function operations. > > - DB: migration `0019` adds `apps.supabase_organization_slug`; drizzle schema updated > - Settings/schema: introduce `supabase.organizations{slug->{tokens,...}}`; helper `isSupabaseConnected`; encrypt/decrypt per-org tokens > - OAuth: `handleSupabaseOAuthReturn` now stores credentials under the detected organization > - IPC: new channels `supabase:list-organizations`, `supabase:delete-organization`, `supabase:list-all-projects`; existing branches/logs/set/unset handlers accept `organizationSlug`; preload allowlist and ipc_client updated > - Supabase management: clients selectable per organization; all calls (projects, branches, logs, SQL, deploy/delete/bulk-update functions, context/client code) accept `organizationSlug` > - UI/hooks: `SupabaseConnector` and `SupabaseIntegration` list/manage organizations, group projects by org, persist org on selection, and handle org-scoped branch switching; `useSupabase` adds org state and org-aware loaders; preview panel loads edge logs with org > - Misc: AGENTS.md adds database/migrations guidance; minor test fixture tweak > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 402bb2cd357dfd9c5d4de28ee68cb4719ca75a51. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds multi-organization Supabase support so you can connect multiple orgs, pick projects by org, and manage tokens per org. Updates UI, settings, IPC, and the app schema to store organization context with each connected project. - **New Features** - Connect multiple Supabase organizations; list, add, and delete organizations. - Project picker groups projects by organization; selection persists organizationSlug on the app. - Branch switching keeps the selected organization context; “Disconnect Project” still works. - Per-organization token storage and refresh; new IPC: supabase:list-organizations, supabase:delete-organization, supabase:list-all-projects. - Settings shows connected organizations and “Disconnect All”; legacy single-account flows continue to work. - **Migration** - Apply SQL migration 0019 to add supabase_organization_slug to the apps table. <sup>Written for commit 402bb2cd357dfd9c5d4de28ee68cb4719ca75a51. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 b36962b5
......@@ -21,6 +21,18 @@ When creating hooks/components that call IPC handlers:
- Wrap writes in `useMutation`; validate inputs locally, call the IPC client, and invalidate related queries on success. Use shared utilities (e.g., toast helpers) in `onError`.
- Synchronize TanStack Query data with any global state (like Jotai atoms) via `useEffect` only if required.
## Database
This app uses SQLite and drizzle ORM.
Generate SQL migrations by running this:
```sh
npm run db:generate
```
IMPORTANT: Do NOT generate SQL migration files by hand! This is wrong.
## General guidance
- Favor descriptive module/function names that mirror IPC channel semantics.
......
ALTER TABLE `apps` ADD `supabase_organization_slug` text;
\ No newline at end of file
{
"version": "6",
"dialect": "sqlite",
"id": "f8636989-52ff-4660-87e2-518bfc2ebfd8",
"prevId": "22201ae3-5058-4e52-a244-e2a6a17ecd9f",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_parent_project_id": {
"name": "supabase_parent_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_organization_slug": {
"name": "supabase_organization_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_commit_hash": {
"name": "source_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_tokens_used": {
"name": "max_tokens_used",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_messages_json": {
"name": "ai_messages_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
\ No newline at end of file
......@@ -134,6 +134,13 @@
"when": 1766124364939,
"tag": "0018_skinny_ezekiel",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1766529533747,
"tag": "0019_cute_carnage",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -5,3 +5,4 @@ This message simulates being close to the model's context window limit.
import { atom } from "jotai";
import { SupabaseBranch } from "@/ipc/ipc_types";
import {
SupabaseBranch,
SupabaseOrganizationInfo,
SupabaseProject,
} from "@/ipc/ipc_types";
// Define atom for storing the list of connected Supabase organizations
export const supabaseOrganizationsAtom = atom<SupabaseOrganizationInfo[]>([]);
// Define atom for storing the list of Supabase projects
export const supabaseProjectsAtom = atom<any[]>([]);
export const supabaseProjectsAtom = atom<SupabaseProject[]>([]);
export const supabaseBranchesAtom = atom<SupabaseBranch[]>([]);
// Define atom for tracking loading state
......
......@@ -10,7 +10,9 @@ import { useSupabase } from "@/hooks/useSupabase";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
......@@ -21,6 +23,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useDeepLink } from "@/contexts/DeepLinkContext";
......@@ -34,8 +37,10 @@ import connectSupabaseDark from "../../assets/supabase/connect-supabase-dark.svg
// @ts-ignore
import connectSupabaseLight from "../../assets/supabase/connect-supabase-light.svg";
import { ExternalLink } from "lucide-react";
import { ExternalLink, Plus, Trash2 } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import type { SupabaseProject } from "@/ipc/ipc_types";
import { isSupabaseConnected } from "@/lib/schemas";
export function SupabaseConnector({ appId }: { appId: number }) {
const { settings, refreshSettings } = useSettings();
......@@ -46,6 +51,8 @@ export function SupabaseConnector({ appId }: { appId: number }) {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "supabase-oauth-return") {
await refreshSettings();
await loadOrganizations();
await loadProjects();
await refreshApp();
clearLastDeepLink();
}
......@@ -53,27 +60,49 @@ export function SupabaseConnector({ appId }: { appId: number }) {
handleDeepLink();
}, [lastDeepLink?.timestamp]);
const {
organizations,
projects,
loading,
error,
loadOrganizations,
deleteOrganization,
loadProjects,
branches,
loadBranches,
setAppProject,
unsetAppProject,
} = useSupabase();
const currentProjectId = app?.supabaseProjectId;
// Check if there are any connected organizations
const isConnected = isSupabaseConnected(settings);
useEffect(() => {
// Load organizations and projects when the component mounts
loadOrganizations();
}, [loadOrganizations]);
useEffect(() => {
// Load projects when the component mounts and user is connected
if (settings?.supabase?.accessToken) {
// Load projects when organizations are available
if (isConnected) {
loadProjects();
}
}, [settings?.supabase?.accessToken, loadProjects]);
}, [isConnected, loadProjects]);
const handleProjectSelect = async (projectId: string) => {
const handleProjectSelect = async (projectValue: string) => {
try {
await setAppProject({ projectId, appId });
// projectValue format: "organizationSlug:projectId"
const [organizationSlug, projectId] = projectValue.split(":");
const project = projects.find(
(p) => p.id === projectId && p.organizationSlug === organizationSlug,
);
if (!project) {
throw new Error("Project not found");
}
await setAppProject({
projectId,
appId,
organizationSlug,
});
toast.success("Project connected to app successfully");
await refreshApp();
} catch (error) {
......@@ -81,13 +110,51 @@ export function SupabaseConnector({ appId }: { appId: number }) {
}
};
// Group projects by organization for display
const groupedProjects = projects.reduce(
(acc, project) => {
const orgKey = project.organizationSlug;
if (!acc[orgKey]) {
// Find the organization info to get the name
const orgInfo = organizations.find(
(o) => o.organizationSlug === project.organizationSlug,
);
acc[orgKey] = {
orgLabel:
orgInfo?.name ||
`Organization ${project.organizationSlug.slice(0, 8)}`,
projects: [],
};
}
acc[orgKey].projects.push(project);
return acc;
},
{} as Record<string, { orgLabel: string; projects: SupabaseProject[] }>,
);
const handleAddAccount = async () => {
if (settings?.isTestMode) {
await IpcClient.getInstance().fakeHandleSupabaseConnect({
appId,
fakeProjectId: "fake-project-id",
});
} else {
await IpcClient.getInstance().openExternalUrl(
"https://supabase-oauth.dyad.sh/api/connect-supabase/login",
);
}
};
const projectIdForBranches =
app?.supabaseParentProjectId || app?.supabaseProjectId;
useEffect(() => {
if (projectIdForBranches) {
loadBranches(projectIdForBranches);
loadBranches(
projectIdForBranches,
app?.supabaseOrganizationSlug ?? undefined,
);
}
}, [projectIdForBranches, loadBranches]);
}, [projectIdForBranches, loadBranches, app?.supabaseOrganizationSlug]);
const handleUnsetProject = async () => {
try {
......@@ -100,97 +167,135 @@ export function SupabaseConnector({ appId }: { appId: number }) {
}
};
if (settings?.supabase?.accessToken) {
if (app?.supabaseProjectName) {
return (
<Card className="mt-1">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Supabase Project{" "}
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
`https://supabase.com/dashboard/project/${app.supabaseProjectId}`,
);
const handleDeleteOrganization = async (organizationSlug: string) => {
try {
await deleteOrganization({ organizationSlug });
toast.success("Organization disconnected successfully");
await loadProjects();
} catch (error) {
toast.error("Failed to disconnect organization: " + error);
}
};
// Connected and has project set
if (isConnected && app?.supabaseProjectName) {
return (
<Card className="mt-1">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Supabase Project{" "}
<Button
variant="outline"
onClick={() => {
IpcClient.getInstance().openExternalUrl(
`https://supabase.com/dashboard/project/${app.supabaseProjectId}`,
);
}}
className="ml-2 px-2 py-1"
style={{ display: "inline-flex", alignItems: "center" }}
asChild
>
<div className="flex items-center gap-2">
<img
src={isDarkMode ? supabaseLogoDark : supabaseLogoLight}
alt="Supabase Logo"
style={{ height: 20, width: "auto", marginRight: 4 }}
/>
<ExternalLink className="h-4 w-4" />
</div>
</Button>
</CardTitle>
<CardDescription className="flex flex-col gap-1.5 text-sm">
This app is connected to project:{" "}
<Badge
variant="secondary"
className="ml-2 text-base font-bold px-3 py-1"
>
{app.supabaseProjectName}
</Badge>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="supabase-branch-select">Database Branch</Label>
<Select
value={app.supabaseProjectId || ""}
onValueChange={async (supabaseBranchProjectId) => {
try {
const branch = branches.find(
(b) => b.projectRef === supabaseBranchProjectId,
);
if (!branch) {
throw new Error("Branch not found");
}
// Keep the same organizationSlug from the app
await setAppProject({
projectId: branch.projectRef,
parentProjectId: branch.parentProjectRef,
appId,
organizationSlug: app.supabaseOrganizationSlug,
});
toast.success("Branch selected");
await refreshApp();
} catch (error) {
toast.error("Failed to set branch: " + error);
}
}}
className="ml-2 px-2 py-1"
style={{ display: "inline-flex", alignItems: "center" }}
asChild
disabled={loading}
>
<div className="flex items-center gap-2">
<img
src={isDarkMode ? supabaseLogoDark : supabaseLogoLight}
alt="Supabase Logo"
style={{ height: 20, width: "auto", marginRight: 4 }}
/>
<ExternalLink className="h-4 w-4" />
</div>
</Button>
</CardTitle>
<CardDescription>
This app is connected to project: {app.supabaseProjectName}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="supabase-branch-select">Database Branch</Label>
<Select
value={app.supabaseProjectId || ""}
onValueChange={async (supabaseBranchProjectId) => {
try {
const branch = branches.find(
(b) => b.projectRef === supabaseBranchProjectId,
);
if (!branch) {
throw new Error("Branch not found");
}
await setAppProject({
projectId: branch.projectRef,
parentProjectId: branch.parentProjectRef,
appId,
});
toast.success("Branch selected");
await refreshApp();
} catch (error) {
toast.error("Failed to set branch: " + error);
}
}}
disabled={loading}
<SelectTrigger
id="supabase-branch-select"
data-testid="supabase-branch-select"
>
<SelectTrigger
id="supabase-branch-select"
data-testid="supabase-branch-select"
>
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
{branches.map((branch) => (
<SelectItem
key={branch.projectRef}
value={branch.projectRef}
>
{branch.name}
{branch.isDefault && " (Default)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="destructive" onClick={handleUnsetProject}>
Disconnect Project
</Button>
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
{branches.map((branch) => (
<SelectItem
key={branch.projectRef}
value={branch.projectRef}
>
{branch.name}
{branch.isDefault && " (Default)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}
<Button variant="destructive" onClick={handleUnsetProject}>
Disconnect Project
</Button>
</div>
</CardContent>
</Card>
);
}
// Connected organizations exist, show project selector
if (isConnected) {
// Build current project value for the select
const currentProjectValue =
app?.supabaseOrganizationSlug && app?.supabaseProjectId
? `${app.supabaseOrganizationSlug}:${app.supabaseProjectId}`
: "";
return (
<Card className="mt-1">
<CardHeader>
<CardTitle>Supabase Projects</CardTitle>
<CardTitle className="flex items-center justify-between">
Supabase Projects
<Button
variant="outline"
size="sm"
onClick={handleAddAccount}
className="gap-1"
>
<Plus className="h-4 w-4" />
Add Organization
</Button>
</CardTitle>
<CardDescription>
Select a Supabase project to connect to this app
</CardDescription>
......@@ -214,39 +319,76 @@ export function SupabaseConnector({ appId }: { appId: number }) {
</div>
) : (
<div className="space-y-4">
{/* Connected organizations list */}
<div className="space-y-2">
<Label>Connected Organizations</Label>
<div className="space-y-1">
{organizations.map((org) => (
<div
key={org.organizationSlug}
className="flex items-center justify-between p-2 rounded-md bg-muted/50 text-sm gap-2"
>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium truncate">
{org.name ||
`Organization ${org.organizationSlug.slice(0, 8)}`}
</span>
{org.ownerEmail && (
<span className="text-xs text-muted-foreground truncate">
{org.ownerEmail}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-destructive shrink-0"
onClick={() =>
handleDeleteOrganization(org.organizationSlug)
}
title="Disconnect organization"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
<span className="text-xs">Disconnect</span>
</Button>
</div>
))}
</div>
</div>
{projects.length === 0 ? (
<p className="text-sm text-gray-500">
No projects found in your Supabase account.
No projects found in your connected Supabase organizations.
</p>
) : (
<>
<div className="space-y-2">
<Label htmlFor="project-select">Project</Label>
<Select
value={currentProjectId || ""}
onValueChange={handleProjectSelect}
>
<SelectTrigger id="project-select">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name || project.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentProjectId && (
<div className="text-sm text-gray-500">
This app is connected to project:{" "}
{projects.find((p) => p.id === currentProjectId)?.name ||
currentProjectId}
</div>
)}
</>
<div className="space-y-2">
<Label htmlFor="project-select">Project</Label>
<Select
value={currentProjectValue}
onValueChange={handleProjectSelect}
>
<SelectTrigger id="project-select">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedProjects).map(
([orgKey, { orgLabel, projects: orgProjects }]) => (
<SelectGroup key={orgKey}>
<SelectLabel>{orgLabel}</SelectLabel>
{orgProjects.map((project) => (
<SelectItem
key={`${project.organizationSlug}:${project.id}`}
value={`${project.organizationSlug}:${project.id}`}
>
{project.name || project.id}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
......@@ -255,28 +397,17 @@ export function SupabaseConnector({ appId }: { appId: number }) {
);
}
// No accounts connected, show connect button
return (
<div className="flex flex-col space-y-4 p-4 border rounded-md">
<div className="flex flex-col md:flex-row items-center justify-between">
<h2 className="text-lg font-medium">Integrations</h2>
<img
onClick={async () => {
if (settings?.isTestMode) {
await IpcClient.getInstance().fakeHandleSupabaseConnect({
appId,
fakeProjectId: "fake-project-id",
});
} else {
await IpcClient.getInstance().openExternalUrl(
"https://supabase-oauth.dyad.sh/api/connect-supabase/login",
);
}
}}
onClick={handleAddAccount}
src={isDarkMode ? connectSupabaseDark : connectSupabaseLight}
alt="Connect to Supabase"
className="w-full h-10 min-h-8 min-w-20 cursor-pointer"
data-testid="connect-supabase-button"
// className="h-10"
/>
</div>
</div>
......
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
// We might need a Supabase icon here, but for now, let's use a generic one or text.
// import { Supabase } from "lucide-react"; // Placeholder
import { DatabaseZap } from "lucide-react"; // Using DatabaseZap as a placeholder
import { DatabaseZap, Trash2 } from "lucide-react"; // Using DatabaseZap as a placeholder
import { useSettings } from "@/hooks/useSettings";
import { useSupabase } from "@/hooks/useSupabase";
import { showSuccess, showError } from "@/lib/toast";
import { isSupabaseConnected } from "@/lib/schemas";
export function SupabaseIntegration() {
const { settings, updateSettings } = useSettings();
const { organizations, loadOrganizations, deleteOrganization } =
useSupabase();
const [isDisconnecting, setIsDisconnecting] = useState(false);
const handleDisconnectFromSupabase = async () => {
useEffect(() => {
loadOrganizations();
}, [loadOrganizations]);
const handleDisconnectAllFromSupabase = async () => {
setIsDisconnecting(true);
try {
// Clear the entire supabase object in settings
// Clear the entire supabase object in settings (including all organizations)
const result = await updateSettings({
supabase: undefined,
// Also disable the migration setting on disconnect
enableSupabaseWriteSqlMigration: false,
});
if (result) {
showSuccess("Successfully disconnected from Supabase");
showSuccess("Successfully disconnected all Supabase organizations");
await loadOrganizations();
} else {
showError("Failed to disconnect from Supabase");
}
......@@ -35,6 +44,15 @@ export function SupabaseIntegration() {
}
};
const handleDeleteOrganization = async (organizationSlug: string) => {
try {
await deleteOrganization({ organizationSlug });
showSuccess("Organization disconnected successfully");
} catch (err: any) {
showError(err.message || "Failed to disconnect organization");
}
};
const handleMigrationSettingChange = async (enabled: boolean) => {
try {
await updateSettings({
......@@ -46,8 +64,8 @@ export function SupabaseIntegration() {
}
};
// Check if there's any Supabase accessToken to determine connection status
const isConnected = !!settings?.supabase?.accessToken;
// Check if there are any connected organizations
const isConnected = isSupabaseConnected(settings);
if (!isConnected) {
return null;
......@@ -61,20 +79,53 @@ export function SupabaseIntegration() {
Supabase Integration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your account is connected to Supabase.
{organizations.length} organization
{organizations.length !== 1 ? "s" : ""} connected to Supabase.
</p>
</div>
<Button
onClick={handleDisconnectFromSupabase}
onClick={handleDisconnectAllFromSupabase}
variant="destructive"
size="sm"
disabled={isDisconnecting}
className="flex items-center gap-2"
>
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"}
{isDisconnecting ? "Disconnecting..." : "Disconnect All"}
<DatabaseZap className="h-4 w-4" />
</Button>
</div>
{/* Connected organizations list */}
<div className="mt-3 space-y-1">
{organizations.map((org) => (
<div
key={org.organizationSlug}
className="flex items-center justify-between p-2 rounded-md bg-muted/50 text-sm gap-2"
>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-gray-700 dark:text-gray-300 font-medium truncate">
{org.name || `Organization ${org.organizationSlug.slice(0, 8)}`}
</span>
{org.ownerEmail && (
<span className="text-xs text-gray-500 dark:text-gray-400 truncate">
{org.ownerEmail}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => handleDeleteOrganization(org.organizationSlug)}
title="Disconnect organization"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
<span className="text-xs">Disconnect</span>
</Button>
</div>
))}
</div>
<div className="mt-4">
<div className="flex items-center space-x-3">
<Switch
......
......@@ -113,22 +113,23 @@ export function PreviewPanel() {
// Load edge logs if app has Supabase project configured
useEffect(() => {
const projectId = app?.supabaseProjectId;
const organizationSlug = app?.supabaseOrganizationSlug ?? undefined;
if (!projectId) return;
// Load logs immediately
loadEdgeLogs(projectId).catch((error) => {
loadEdgeLogs(projectId, organizationSlug).catch((error) => {
console.error("Failed to load edge logs:", error);
});
// Poll for new logs every 5 seconds
const intervalId = setInterval(() => {
loadEdgeLogs(projectId).catch((error) => {
loadEdgeLogs(projectId, organizationSlug).catch((error) => {
console.error("Failed to load edge logs:", error);
});
}, 5000);
return () => clearInterval(intervalId);
}, [app?.supabaseProjectId, loadEdgeLogs]);
}, [app?.supabaseProjectId, app?.supabaseOrganizationSlug, loadEdgeLogs]);
return (
<div className="flex flex-col h-full">
......
......@@ -43,6 +43,8 @@ export const apps = sqliteTable("apps", {
// This is only used for display purposes but is NOT used for any actual
// supabase management logic.
supabaseParentProjectId: text("supabase_parent_project_id"),
// Supabase organization slug for credential lookup
supabaseOrganizationSlug: text("supabase_organization_slug"),
neonProjectId: text("neon_project_id"),
neonDevelopmentBranchId: text("neon_development_branch_id"),
neonPreviewBranchId: text("neon_preview_branch_id"),
......
import { useCallback } from "react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
supabaseOrganizationsAtom,
supabaseProjectsAtom,
supabaseBranchesAtom,
supabaseLoadingAtom,
......@@ -10,9 +11,13 @@ import {
} from "@/atoms/supabaseAtoms";
import { appConsoleEntriesAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { SetSupabaseAppProjectParams } from "@/ipc/ipc_types";
import {
SetSupabaseAppProjectParams,
DeleteSupabaseOrganizationParams,
} from "@/ipc/ipc_types";
export function useSupabase() {
const [organizations, setOrganizations] = useAtom(supabaseOrganizationsAtom);
const [projects, setProjects] = useAtom(supabaseProjectsAtom);
const [branches, setBranches] = useAtom(supabaseBranchesAtom);
const [loading, setLoading] = useAtom(supabaseLoadingAtom);
......@@ -27,12 +32,52 @@ export function useSupabase() {
const ipcClient = IpcClient.getInstance();
/**
* Load Supabase projects from the API
* Load all connected Supabase organizations
*/
const loadOrganizations = useCallback(async () => {
setLoading(true);
try {
const orgList = await ipcClient.listSupabaseOrganizations();
setOrganizations(orgList);
setError(null);
} catch (error) {
console.error("Error loading Supabase organizations:", error);
setError(error instanceof Error ? error : new Error(String(error)));
} finally {
setLoading(false);
}
}, [ipcClient, setOrganizations, setError, setLoading]);
/**
* Delete a Supabase organization connection
*/
const deleteOrganization = useCallback(
async (params: DeleteSupabaseOrganizationParams) => {
setLoading(true);
try {
await ipcClient.deleteSupabaseOrganization(params);
// Refresh organizations list after deletion
const orgList = await ipcClient.listSupabaseOrganizations();
setOrganizations(orgList);
setError(null);
} catch (error) {
console.error("Error deleting Supabase organization:", error);
setError(error instanceof Error ? error : new Error(String(error)));
throw error;
} finally {
setLoading(false);
}
},
[ipcClient, setOrganizations, setError, setLoading],
);
/**
* Load Supabase projects from all connected organizations
*/
const loadProjects = useCallback(async () => {
setLoading(true);
try {
const projectList = await ipcClient.listSupabaseProjects();
const projectList = await ipcClient.listAllSupabaseProjects();
setProjects(projectList);
setError(null);
} catch (error) {
......@@ -47,10 +92,13 @@ export function useSupabase() {
* Load branches for a Supabase project
*/
const loadBranches = useCallback(
async (projectId: string) => {
async (projectId: string, organizationSlug?: string) => {
setLoading(true);
try {
const list = await ipcClient.listSupabaseBranches({ projectId });
const list = await ipcClient.listSupabaseBranches({
projectId,
organizationSlug: organizationSlug ?? null,
});
setBranches(Array.isArray(list) ? list : []);
setError(null);
} catch (error) {
......@@ -108,7 +156,7 @@ export function useSupabase() {
* Uses timestamp tracking to only fetch new logs on subsequent calls
*/
const loadEdgeLogs = useCallback(
async (projectId: string) => {
async (projectId: string, organizationSlug?: string) => {
if (!selectedAppId) return;
// Use last timestamp if available, otherwise fetch logs from the past 10 minutes
......@@ -122,6 +170,7 @@ export function useSupabase() {
projectId,
timestampStart,
appId: selectedAppId,
organizationSlug: organizationSlug ?? null,
});
if (logs.length === 0) {
......@@ -178,11 +227,14 @@ export function useSupabase() {
);
return {
organizations,
projects,
branches,
loading,
error,
selectedProject,
loadOrganizations,
deleteOrganization,
loadProjects,
loadBranches,
loadEdgeLogs,
......
......@@ -723,9 +723,16 @@ export function registerAppHandlers() {
let supabaseProjectName: string | null = null;
const settings = readSettings();
if (app.supabaseProjectId && settings.supabase?.accessToken?.value) {
// Check for multi-organization credentials or legacy single account
const hasSupabaseCredentials =
(app.supabaseOrganizationSlug &&
settings.supabase?.organizations?.[app.supabaseOrganizationSlug]
?.accessToken?.value) ||
settings.supabase?.accessToken?.value;
if (app.supabaseProjectId && hasSupabaseCredentials) {
supabaseProjectName = await getSupabaseProjectName(
app.supabaseParentProjectId || app.supabaseProjectId,
app.supabaseOrganizationSlug ?? undefined,
);
}
......@@ -1073,6 +1080,7 @@ export function registerAppHandlers() {
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: app.supabaseProjectId,
supabaseOrganizationSlug: app.supabaseOrganizationSlug ?? null,
});
if (deployErrors.length > 0) {
return {
......@@ -1096,6 +1104,7 @@ export function registerAppHandlers() {
supabaseProjectId: app.supabaseProjectId,
functionName,
appPath,
organizationSlug: app.supabaseOrganizationSlug ?? null,
});
} catch (error) {
logger.error(
......
......@@ -80,7 +80,7 @@ import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference";
import { mcpManager } from "../utils/mcp_manager";
import z from "zod";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { isSupabaseConnected, isTurboEditsV2Enabled } from "@/lib/schemas";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils";
import {
......@@ -649,7 +649,7 @@ ${componentSnippet}
if (
updatedChat.app?.supabaseProjectId &&
settings.supabase?.accessToken?.value
isSupabaseConnected(settings)
) {
systemPrompt +=
"\n\n" +
......@@ -660,6 +660,8 @@ ${componentSnippet}
? ""
: await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId,
organizationSlug:
updatedChat.app.supabaseOrganizationSlug ?? null,
}));
} else if (
// Neon projects don't need Supabase.
......@@ -954,6 +956,8 @@ This conversation includes one or more image attachments. When the user uploads
) {
const supabaseClientCode = await getSupabaseClientCode({
projectId: updatedChat.app?.supabaseProjectId,
organizationSlug:
updatedChat.app?.supabaseOrganizationSlug ?? null,
});
fullResponse = fullResponse.replace(
"$$SUPABASE_CLIENT_CODE$$",
......
......@@ -713,6 +713,7 @@ async function handleCloneRepoFromUrl(
...newApp,
files: [],
supabaseProjectName: null,
supabaseOrganizationSlug: null,
vercelTeamSlug: null,
},
hasAiRules,
......
......@@ -3,19 +3,28 @@ import { db } from "../../db";
import { eq } from "drizzle-orm";
import { apps } from "../../db/schema";
import {
getSupabaseClient,
getSupabaseClientForOrganization,
listSupabaseBranches,
getSupabaseProjectLogs,
getOrganizationDetails,
getOrganizationMembers,
type SupabaseProjectLog,
} from "../../supabase_admin/supabase_management_client";
import { extractFunctionName } from "../../supabase_admin/supabase_utils";
import {
createLoggedHandler,
createTestOnlyLoggedHandler,
} from "./safe_handle";
import { handleSupabaseOAuthReturn } from "../../supabase_admin/supabase_return_handler";
import { safeSend } from "../utils/safe_sender";
import { readSettings, writeSettings } from "../../main/settings";
import { SetSupabaseAppProjectParams, SupabaseBranch } from "../ipc_types";
import {
SetSupabaseAppProjectParams,
SupabaseBranch,
SupabaseOrganizationInfo,
SupabaseProject,
DeleteSupabaseOrganizationParams,
} from "../ipc_types";
import type { ConsoleEntry } from "../../atoms/appAtoms";
const logger = log.scope("supabase_handlers");
......@@ -23,9 +32,105 @@ const handle = createLoggedHandler(logger);
const testOnlyHandle = createTestOnlyLoggedHandler(logger);
export function registerSupabaseHandlers() {
handle("supabase:list-projects", async () => {
const supabase = await getSupabaseClient();
return supabase.getProjects();
// List all connected Supabase organizations with details
handle(
"supabase:list-organizations",
async (): Promise<SupabaseOrganizationInfo[]> => {
const settings = readSettings();
const organizations = settings.supabase?.organizations ?? {};
const results: SupabaseOrganizationInfo[] = [];
for (const organizationSlug of Object.keys(organizations)) {
try {
// Fetch organization details and members in parallel
const [details, members] = await Promise.all([
getOrganizationDetails(organizationSlug),
getOrganizationMembers(organizationSlug),
]);
// Find the owner from members
const owner = members.find((m) => m.role === "Owner");
results.push({
organizationSlug,
name: details.name,
ownerEmail: owner?.email,
});
} catch (error) {
// If we can't fetch details, still include the org with just the ID
logger.error(
`Failed to fetch details for organization ${organizationSlug}:`,
error,
);
results.push({ organizationSlug });
}
}
return results;
},
);
// Delete a Supabase organization connection
handle(
"supabase:delete-organization",
async (_, { organizationSlug }: DeleteSupabaseOrganizationParams) => {
const settings = readSettings();
const organizations = { ...settings.supabase?.organizations };
if (!organizations[organizationSlug]) {
throw new Error(`Supabase organization ${organizationSlug} not found`);
}
delete organizations[organizationSlug];
writeSettings({
supabase: {
...settings.supabase,
organizations,
},
});
logger.info(`Deleted Supabase organization ${organizationSlug}`);
},
);
// List all projects from all connected organizations
handle("supabase:list-all-projects", async (): Promise<SupabaseProject[]> => {
const settings = readSettings();
const organizations = settings.supabase?.organizations ?? {};
const allProjects: SupabaseProject[] = [];
for (const organizationSlug of Object.keys(organizations)) {
try {
const client = await getSupabaseClientForOrganization(organizationSlug);
const projects = await client.getProjects();
if (projects) {
for (const project of projects) {
allProjects.push({
id: project.id,
name: project.name,
region: project.region,
organizationSlug:
// The supabase management API typedef is out of date and there's
// actually an organization_slug field.
// Just in case it's not there, we fallback to organization_id
// which in practice is the same value as the slug.
(project as any).organization_slug || project.organization_id,
});
}
}
} catch (error) {
logger.error(
`Failed to fetch projects for organization ${organizationSlug}:`,
error,
);
// Continue with other organizations even if one fails
}
}
return allProjects;
});
// List branches for a Supabase project (database branches)
......@@ -33,10 +138,14 @@ export function registerSupabaseHandlers() {
"supabase:list-branches",
async (
_,
{ projectId }: { projectId: string },
{
projectId,
organizationSlug,
}: { projectId: string; organizationSlug?: string },
): Promise<Array<SupabaseBranch>> => {
const branches = await listSupabaseBranches({
supabaseProjectId: projectId,
organizationSlug: organizationSlug ?? null,
});
return branches.map((branch) => ({
id: branch.id,
......@@ -57,9 +166,19 @@ export function registerSupabaseHandlers() {
projectId,
timestampStart,
appId,
}: { projectId: string; timestampStart?: number; appId: number },
organizationSlug,
}: {
projectId: string;
timestampStart?: number;
appId: number;
organizationSlug: string | null;
},
): Promise<Array<ConsoleEntry>> => {
const response = await getSupabaseProjectLogs(projectId, timestampStart);
const response = await getSupabaseProjectLogs(
projectId,
timestampStart,
organizationSlug ?? undefined,
);
if (response.error) {
const errorMsg =
......@@ -72,7 +191,7 @@ export function registerSupabaseHandlers() {
const rawLogs = response.result || [];
// Transform to ConsoleEntry format
return rawLogs.map((log: any) => {
return rawLogs.map((log: SupabaseProjectLog) => {
const metadata = log.metadata?.[0] || {};
const level = metadata.level || "info";
const eventMessage = log.event_message || "";
......@@ -96,18 +215,24 @@ export function registerSupabaseHandlers() {
"supabase:set-app-project",
async (
_,
{ projectId, appId, parentProjectId }: SetSupabaseAppProjectParams,
{
projectId,
appId,
parentProjectId,
organizationSlug,
}: SetSupabaseAppProjectParams,
) => {
await db
.update(apps)
.set({
supabaseProjectId: projectId,
supabaseParentProjectId: parentProjectId,
supabaseOrganizationSlug: organizationSlug,
})
.where(eq(apps.id, appId));
logger.info(
`Associated app ${appId} with Supabase project ${projectId} ${parentProjectId ? `and parent project ${parentProjectId}` : ""}`,
`Associated app ${appId} with Supabase project ${projectId} (organization: ${organizationSlug})${parentProjectId ? ` and parent project ${parentProjectId}` : ""}`,
);
},
);
......@@ -116,7 +241,11 @@ export function registerSupabaseHandlers() {
handle("supabase:unset-app-project", async (_, { app }: { app: number }) => {
await db
.update(apps)
.set({ supabaseProjectId: null, supabaseParentProjectId: null })
.set({
supabaseProjectId: null,
supabaseParentProjectId: null,
supabaseOrganizationSlug: null,
})
.where(eq(apps.id, app));
logger.info(`Removed Supabase project association for app ${app}`);
......@@ -128,14 +257,33 @@ export function registerSupabaseHandlers() {
event,
{ appId, fakeProjectId }: { appId: number; fakeProjectId: string },
) => {
// Call handleSupabaseOAuthReturn with fake data
handleSupabaseOAuthReturn({
token: "fake-access-token",
refreshToken: "fake-refresh-token",
expiresIn: 3600, // 1 hour
const fakeOrgId = "fake-org-id";
// Directly store fake credentials in the organizations map
// We don't call handleSupabaseOAuthReturn because it attempts a real API call
// which fails with fake tokens, causing credentials to be stored in legacy format
const settings = readSettings();
const existingOrgs = settings.supabase?.organizations ?? {};
writeSettings({
supabase: {
...settings.supabase,
organizations: {
...existingOrgs,
[fakeOrgId]: {
accessToken: {
value: "fake-access-token",
},
refreshToken: {
value: "fake-refresh-token",
},
expiresIn: 3600,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
},
},
});
logger.info(
`Called handleSupabaseOAuthReturn with fake data for app ${appId} during testing.`,
`Stored fake Supabase credentials for organization ${fakeOrgId} for app ${appId} during testing.`,
);
// Set the supabase project for the currently selected app
......@@ -143,6 +291,7 @@ export function registerSupabaseHandlers() {
.update(apps)
.set({
supabaseProjectId: fakeProjectId,
supabaseOrganizationSlug: fakeOrgId,
})
.where(eq(apps.id, appId));
logger.info(
......
......@@ -76,6 +76,7 @@ export function registerTokenCountHandlers() {
systemPrompt += "\n\n" + SUPABASE_AVAILABLE_SYSTEM_PROMPT;
supabaseContext = await getSupabaseContext({
supabaseProjectId: chat.app.supabaseProjectId,
organizationSlug: chat.app.supabaseOrganizationSlug ?? null,
});
} else if (
// Neon projects don't need Supabase.
......
......@@ -322,6 +322,7 @@ export function registerVersionHandlers() {
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: app.supabaseProjectId,
supabaseOrganizationSlug: app.supabaseOrganizationSlug ?? null,
});
if (deployErrors.length > 0) {
......
......@@ -69,6 +69,9 @@ import type {
CloneRepoParams,
SupabaseBranch,
SetSupabaseAppProjectParams,
SupabaseOrganizationInfo,
SupabaseProject,
DeleteSupabaseOrganizationParams,
SelectNodeFolderResult,
ApplyVisualEditingChangesParams,
AnalyseComponentParams,
......@@ -1041,12 +1044,29 @@ export class IpcClient {
// --- End Proposal Management ---
// --- Supabase Management ---
public async listSupabaseProjects(): Promise<any[]> {
return this.ipcRenderer.invoke("supabase:list-projects");
// List all connected Supabase organizations
public async listSupabaseOrganizations(): Promise<
SupabaseOrganizationInfo[]
> {
return this.ipcRenderer.invoke("supabase:list-organizations");
}
// Delete a Supabase organization connection
public async deleteSupabaseOrganization(
params: DeleteSupabaseOrganizationParams,
): Promise<void> {
await this.ipcRenderer.invoke("supabase:delete-organization", params);
}
// List all projects from all connected organizations
public async listAllSupabaseProjects(): Promise<SupabaseProject[]> {
return this.ipcRenderer.invoke("supabase:list-all-projects");
}
public async listSupabaseBranches(params: {
projectId: string;
organizationSlug: string | null;
}): Promise<SupabaseBranch[]> {
return this.ipcRenderer.invoke("supabase:list-branches", params);
}
......@@ -1055,6 +1075,7 @@ export class IpcClient {
projectId: string;
timestampStart?: number;
appId: number;
organizationSlug: string | null;
}): Promise<Array<ConsoleEntry>> {
return this.ipcRenderer.invoke("supabase:get-edge-logs", params);
}
......
......@@ -107,6 +107,7 @@ export interface App {
supabaseProjectId: string | null;
supabaseParentProjectId: string | null;
supabaseProjectName: string | null;
supabaseOrganizationSlug: string | null;
neonProjectId: string | null;
neonDevelopmentBranchId: string | null;
neonPreviewBranchId: string | null;
......@@ -537,10 +538,34 @@ export interface SupabaseBranch {
parentProjectRef: string;
}
/**
* Supabase organization info for display (without secrets).
*/
export interface SupabaseOrganizationInfo {
organizationSlug: string;
name?: string;
ownerEmail?: string;
}
/**
* Supabase project info.
*/
export interface SupabaseProject {
id: string;
name: string;
region?: string;
organizationSlug: string;
}
export interface SetSupabaseAppProjectParams {
projectId: string;
parentProjectId?: string;
appId: number;
organizationSlug: string | null;
}
export interface DeleteSupabaseOrganizationParams {
organizationSlug: string;
}
// Supabase Logs
......
......@@ -185,6 +185,7 @@ export async function processFullResponseActions(
await executeSupabaseSql({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
query: query.content,
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
});
// Only write migration file if SQL execution succeeded
......@@ -287,6 +288,7 @@ export async function processFullResponseActions(
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(filePath),
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
});
} catch (error) {
errors.push({
......@@ -334,6 +336,7 @@ export async function processFullResponseActions(
await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(tag.from),
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
});
} catch (error) {
warnings.push({
......@@ -349,6 +352,7 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(tag.to),
appPath,
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
});
} catch (error) {
errors.push({
......@@ -396,6 +400,8 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(filePath),
appPath,
organizationSlug:
chatWithApp.app.supabaseOrganizationSlug ?? null,
});
} catch (error) {
errors.push({
......@@ -466,6 +472,7 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(filePath),
appPath,
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
});
} catch (error) {
errors.push({
......@@ -485,6 +492,8 @@ export async function processFullResponseActions(
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: chatWithApp.app.supabaseProjectId,
supabaseOrganizationSlug:
chatWithApp.app.supabaseOrganizationSlug ?? null,
});
if (deployErrors.length > 0) {
for (const err of deployErrors) {
......
......@@ -155,7 +155,27 @@ export const GithubUserSchema = z.object({
});
export type GithubUser = z.infer<typeof GithubUserSchema>;
/**
* Supabase organization credentials.
* Each organization has its own OAuth tokens.
*/
export const SupabaseOrganizationCredentialsSchema = z.object({
accessToken: SecretSchema,
refreshToken: SecretSchema,
expiresIn: z.number(),
tokenTimestamp: z.number(),
});
export type SupabaseOrganizationCredentials = z.infer<
typeof SupabaseOrganizationCredentialsSchema
>;
export const SupabaseSchema = z.object({
// Map keyed by organizationSlug -> organization credentials
organizations: z
.record(z.string(), SupabaseOrganizationCredentialsSchema)
.optional(),
// Legacy fields - kept for backwards compat
accessToken: SecretSchema.optional(),
refreshToken: SecretSchema.optional(),
expiresIn: z.number().optional(),
......@@ -301,6 +321,17 @@ export function hasDyadProKey(settings: UserSettings): boolean {
return !!settings.providerSettings?.auto?.apiKey?.value;
}
export function isSupabaseConnected(settings: UserSettings | null): boolean {
if (!settings) {
return false;
}
return Boolean(
settings.supabase?.accessToken ||
(settings.supabase?.organizations &&
Object.keys(settings.supabase.organizations).length > 0),
);
}
export function isTurboEditsV2Enabled(settings: UserSettings): boolean {
return Boolean(
isDyadProEnabled(settings) &&
......
......@@ -308,7 +308,7 @@ app.on("open-url", (event, url) => {
handleDeepLinkReturn(url);
});
function handleDeepLinkReturn(url: string) {
async function handleDeepLinkReturn(url: string) {
// example url: "dyad://supabase-oauth-return?token=a&refreshToken=b"
let parsed: URL;
try {
......@@ -361,7 +361,7 @@ function handleDeepLinkReturn(url: string) {
);
return;
}
handleSupabaseOAuthReturn({ token, refreshToken, expiresIn });
await handleSupabaseOAuthReturn({ token, refreshToken, expiresIn });
// Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
......
......@@ -58,6 +58,7 @@ export function readSettings(): UserSettings {
};
const supabase = combinedSettings.supabase;
if (supabase) {
// Decrypt legacy tokens (kept but ignored)
if (supabase.refreshToken) {
const encryptionType = supabase.refreshToken.encryptionType;
if (encryptionType) {
......@@ -76,6 +77,30 @@ export function readSettings(): UserSettings {
};
}
}
// Decrypt tokens for each organization in the organizations map
if (supabase.organizations) {
for (const orgId in supabase.organizations) {
const org = supabase.organizations[orgId];
if (org.accessToken) {
const encryptionType = org.accessToken.encryptionType;
if (encryptionType) {
org.accessToken = {
value: decrypt(org.accessToken),
encryptionType,
};
}
}
if (org.refreshToken) {
const encryptionType = org.refreshToken.encryptionType;
if (encryptionType) {
org.refreshToken = {
value: decrypt(org.refreshToken),
encryptionType,
};
}
}
}
}
}
const neon = combinedSettings.neon;
if (neon) {
......@@ -163,6 +188,7 @@ export function writeSettings(settings: Partial<UserSettings>): void {
);
}
if (newSettings.supabase) {
// Encrypt legacy tokens (kept for backwards compat)
if (newSettings.supabase.accessToken) {
newSettings.supabase.accessToken = encrypt(
newSettings.supabase.accessToken.value,
......@@ -173,6 +199,18 @@ export function writeSettings(settings: Partial<UserSettings>): void {
newSettings.supabase.refreshToken.value,
);
}
// Encrypt tokens for each organization in the organizations map
if (newSettings.supabase.organizations) {
for (const orgId in newSettings.supabase.organizations) {
const org = newSettings.supabase.organizations[orgId];
if (org.accessToken) {
org.accessToken = encrypt(org.accessToken.value);
}
if (org.refreshToken) {
org.refreshToken = encrypt(org.refreshToken.value);
}
}
}
}
if (newSettings.neon) {
if (newSettings.neon.accessToken) {
......
......@@ -78,7 +78,9 @@ const validInvokeChannels = [
"approve-proposal",
"reject-proposal",
"get-system-debug-info",
"supabase:list-projects",
"supabase:list-organizations",
"supabase:delete-organization",
"supabase:list-all-projects",
"supabase:list-branches",
"supabase:get-edge-logs",
"supabase:set-app-project",
......
......@@ -147,6 +147,7 @@ export async function handleLocalAgentStream(
appPath,
chatId: chat.id,
supabaseProjectId: chat.app.supabaseProjectId,
supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug,
messageId: placeholderMessageId,
isSharedModulesChanged: false,
onXmlStream: (accumulatedXml: string) => {
......
......@@ -25,7 +25,10 @@ export interface FileOperationResult {
export async function deployAllFunctionsIfNeeded(
ctx: Pick<
AgentContext,
"appPath" | "supabaseProjectId" | "isSharedModulesChanged"
| "appPath"
| "supabaseProjectId"
| "supabaseOrganizationSlug"
| "isSharedModulesChanged"
>,
): Promise<FileOperationResult> {
if (!ctx.supabaseProjectId || !ctx.isSharedModulesChanged) {
......@@ -37,6 +40,7 @@ export async function deployAllFunctionsIfNeeded(
const deployErrors = await deployAllSupabaseFunctions({
appPath: ctx.appPath,
supabaseProjectId: ctx.supabaseProjectId,
supabaseOrganizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
if (deployErrors.length > 0) {
......
......@@ -196,6 +196,7 @@ async function processArgPlaceholders<T extends Record<string, any>>(
// Fetch the replacement value once
const supabaseClientCode = await getSupabaseClientCode({
projectId: ctx.supabaseProjectId,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
// Process all string values in args
......
......@@ -64,6 +64,7 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> =
await deleteSupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.path),
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
} catch (error) {
return `File deleted, but failed to delete Supabase function: ${error}`;
......
......@@ -38,6 +38,7 @@ export const executeSqlTool: ToolDefinition<z.infer<typeof executeSqlSchema>> =
await executeSupabaseSql({
supabaseProjectId: ctx.supabaseProjectId,
query: args.query,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
// Write migration file if enabled
......
......@@ -29,6 +29,7 @@ export const getDatabaseSchemaTool: ToolDefinition<
const schema = await getSupabaseContext({
supabaseProjectId: ctx.supabaseProjectId,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
return schema || "";
......
......@@ -73,6 +73,7 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
await deleteSupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.from),
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
} catch (error) {
logger.warn(
......@@ -87,6 +88,7 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.to),
appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
} catch (error) {
return `File renamed, but failed to deploy Supabase function: ${error}`;
......
......@@ -98,6 +98,7 @@ export const searchReplaceTool: ToolDefinition<
supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
} catch (error) {
return `Search-replace applied, but failed to deploy Supabase function: ${error}`;
......
......@@ -27,9 +27,10 @@ export interface AgentContext {
event: IpcMainInvokeEvent;
appPath: string;
chatId: number;
supabaseProjectId?: string | null;
messageId?: number;
isSharedModulesChanged?: boolean;
supabaseProjectId: string | null;
supabaseOrganizationSlug: string | null;
messageId: number;
isSharedModulesChanged: boolean;
chatSummary?: string;
/**
* Streams accumulated XML to UI without persisting to DB (for live preview).
......
......@@ -66,6 +66,7 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = {
supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
} catch (error) {
return `File written, but failed to deploy Supabase function: ${error}`;
......
......@@ -2,12 +2,18 @@ import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { getSupabaseClient } from "./supabase_management_client";
import { SUPABASE_SCHEMA_QUERY } from "./supabase_schema_query";
async function getPublishableKey({ projectId }: { projectId: string }) {
async function getPublishableKey({
projectId,
organizationSlug,
}: {
projectId: string;
organizationSlug: string | null;
}) {
if (IS_TEST_BUILD) {
return "test-publishable-key";
}
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
let keys;
try {
keys = await supabase.getProjectApiKeys(projectId);
......@@ -33,10 +39,15 @@ async function getPublishableKey({ projectId }: { projectId: string }) {
}
export const getSupabaseClientCode = async function ({
projectId,
organizationSlug,
}: {
projectId: string;
organizationSlug: string | null;
}) {
const publishableKey = await getPublishableKey({ projectId });
const publishableKey = await getPublishableKey({
projectId,
organizationSlug,
});
return `
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
......@@ -52,8 +63,10 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);`;
export async function getSupabaseContext({
supabaseProjectId,
organizationSlug,
}: {
supabaseProjectId: string;
organizationSlug: string | null;
}) {
if (IS_TEST_BUILD) {
if (supabaseProjectId === "test-branch-project-id") {
......@@ -62,9 +75,10 @@ export async function getSupabaseContext({
return "[[TEST_BUILD_SUPABASE_CONTEXT]]";
}
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
const publishableKey = await getPublishableKey({
projectId: supabaseProjectId,
organizationSlug,
});
const schema = await supabase.runQuery(
supabaseProjectId,
......
......@@ -8,6 +8,7 @@ import {
} from "@dyad-sh/supabase-management-js";
import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import type { SupabaseOrganizationCredentials } from "../lib/schemas";
const fsPromises = fs.promises;
......@@ -57,6 +58,25 @@ export interface DeployedFunctionResponse {
ezbr_sha256?: string;
}
export interface SupabaseProjectLog {
timestamp: number;
event_message: string;
metadata: any;
}
export interface SupabaseProjectLogsResponse {
result: SupabaseProjectLog[];
error?: any;
}
export interface SupabaseProjectBranch {
id: string;
name: string;
is_default: boolean;
project_ref: string;
parent_project_ref: string;
}
// Caches for shared files to avoid re-reading unchanged files
const sharedFilesCache = new Map<string, CachedSharedFiles>();
......@@ -139,7 +159,15 @@ export async function refreshSupabaseToken(): Promise<void> {
}
// Function to get the Supabase Management API client
export async function getSupabaseClient(): Promise<SupabaseManagementAPI> {
export async function getSupabaseClient({
organizationSlug,
}: { organizationSlug?: string | null } = {}): Promise<SupabaseManagementAPI> {
// If organizationSlug provided, use organization-specific credentials
if (organizationSlug) {
return getSupabaseClientForOrganization(organizationSlug);
}
// Otherwise fall back to legacy single-account credentials
const settings = readSettings();
// Check if Supabase token exists in settings
......@@ -173,14 +201,301 @@ export async function getSupabaseClient(): Promise<SupabaseManagementAPI> {
});
}
// ─────────────────────────────────────────────────────────────────────
// Multi-organization support
// ─────────────────────────────────────────────────────────────────────
/**
* Checks if an organization's token is expired or about to expire.
*/
function isOrganizationTokenExpired(
org: SupabaseOrganizationCredentials,
): boolean {
if (!org.expiresIn || !org.tokenTimestamp) return true;
const currentTime = Math.floor(Date.now() / 1000);
// Check if the token is expired or about to expire (within 5 minutes)
return currentTime >= org.tokenTimestamp + org.expiresIn - 300;
}
/**
* Refreshes the Supabase access token for a specific organization.
*/
async function refreshSupabaseTokenForOrganization(
organizationSlug: string,
): Promise<void> {
const settings = readSettings();
const org = settings.supabase?.organizations?.[organizationSlug];
if (!org) {
throw new Error(
`Supabase organization ${organizationSlug} not found. Please authenticate first.`,
);
}
if (!isOrganizationTokenExpired(org)) {
return;
}
const refreshToken = org.refreshToken?.value;
if (!refreshToken) {
throw new Error(
"Supabase refresh token not found. Please authenticate first.",
);
}
try {
const response = await fetch(
"https://supabase-oauth.dyad.sh/api/connect-supabase/refresh",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshToken }),
},
);
if (!response.ok) {
throw new Error(
`Supabase token refresh failed. Try going to Settings to disconnect Supabase and then reconnect. Error status: ${response.statusText}`,
);
}
const {
accessToken,
refreshToken: newRefreshToken,
expiresIn,
} = await response.json();
// Update the specific organization in settings
const existingOrgs = settings.supabase?.organizations ?? {};
writeSettings({
supabase: {
...settings.supabase,
organizations: {
...existingOrgs,
[organizationSlug]: {
...org,
accessToken: {
value: accessToken,
},
refreshToken: {
value: newRefreshToken,
},
expiresIn,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
},
},
});
} catch (error) {
logger.error(
`Error refreshing Supabase token for organization ${organizationSlug}:`,
error,
);
throw error;
}
}
/**
* Gets a Supabase Management API client for a specific organization.
*/
export async function getSupabaseClientForOrganization(
organizationSlug: string,
): Promise<SupabaseManagementAPI> {
const settings = readSettings();
const org = settings.supabase?.organizations?.[organizationSlug];
if (!org) {
throw new Error(
`Supabase organization ${organizationSlug} not found. Please authenticate first.`,
);
}
const accessToken = org.accessToken?.value;
if (!accessToken) {
throw new Error(
`Supabase access token not found for organization ${organizationSlug}. Please authenticate first.`,
);
}
// Check if token needs refreshing
if (isOrganizationTokenExpired(org)) {
await withLock(`refresh-supabase-token-${organizationSlug}`, () =>
refreshSupabaseTokenForOrganization(organizationSlug),
);
// Get updated settings after refresh
const updatedSettings = readSettings();
const updatedOrg =
updatedSettings.supabase?.organizations?.[organizationSlug];
const newAccessToken = updatedOrg?.accessToken?.value;
if (!newAccessToken) {
throw new Error(
`Failed to refresh Supabase access token for organization ${organizationSlug}`,
);
}
return new SupabaseManagementAPI({
accessToken: newAccessToken,
});
}
return new SupabaseManagementAPI({
accessToken,
});
}
/**
* Lists organizations for a given access token.
*/
export async function listSupabaseOrganizations(
accessToken: string,
): Promise<SupabaseOrganizationDetails[]> {
const response = await fetch("https://api.supabase.com/v1/organizations", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status !== 200) {
const errorText = await response.text();
logger.error(
`Failed to fetch organizations (${response.status}): ${errorText}`,
);
throw new Error(`Failed to fetch organizations: ${response.statusText}`);
}
const organizations: SupabaseOrganizationDetails[] = await response.json();
return organizations;
}
export interface SupabaseOrganizationMember {
userId: string;
email: string;
role: string; // "Owner" | "Member" | etc.
username?: string;
}
interface SupabaseRawMember {
user_id: string;
primary_email?: string;
email: string;
role_name: string;
username?: string;
}
/**
* Gets members of a Supabase organization.
*/
export async function getOrganizationMembers(
organizationSlug: string,
): Promise<SupabaseOrganizationMember[]> {
if (IS_TEST_BUILD) {
return [
{
userId: "fake-user-id",
email: "owner@example.com",
role: "Owner",
username: "owner",
},
];
}
const client = await getSupabaseClientForOrganization(organizationSlug);
const accessToken = (client as any).options.accessToken;
const response = await fetch(
`https://api.supabase.com/v1/organizations/${organizationSlug}/members`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (response.status !== 200) {
const errorText = await response.text();
logger.error(
`Failed to fetch organization members (${response.status}): ${errorText}`,
);
throw new Error(
`Failed to fetch organization members: ${response.statusText}`,
);
}
const members: SupabaseRawMember[] = await response.json();
return members.map((m) => ({
userId: m.user_id,
email: m.primary_email || m.email,
role: m.role_name,
username: m.username,
}));
}
export interface SupabaseOrganizationDetails {
id: string;
name: string;
slug: string;
}
/**
* Gets details about a Supabase organization.
*/
export async function getOrganizationDetails(
organizationSlug: string,
): Promise<SupabaseOrganizationDetails> {
if (IS_TEST_BUILD) {
return {
id: organizationSlug,
name: "Fake Organization",
slug: "fake-org",
};
}
const client = await getSupabaseClientForOrganization(organizationSlug);
const accessToken = (client as any).options.accessToken;
const response = await fetch(
`https://api.supabase.com/v1/organizations/${organizationSlug}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (response.status !== 200) {
const errorText = await response.text();
logger.error(
`Failed to fetch organization details (${response.status}): ${errorText}`,
);
throw new Error(
`Failed to fetch organization details: ${response.statusText}`,
);
}
const org = await response.json();
return {
id: org.id,
name: org.name,
slug: org.slug,
};
}
export async function getSupabaseProjectName(
projectId: string,
organizationSlug?: string,
): Promise<string> {
if (IS_TEST_BUILD) {
return "Fake Supabase Project";
}
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
const projects = await supabase.getProjects();
const project = projects?.find((p) => p.id === projectId);
return project?.name || `<project not found for: ${projectId}>`;
......@@ -189,8 +504,9 @@ export async function getSupabaseProjectName(
export async function getSupabaseProjectLogs(
projectId: string,
timestampStart?: number,
): Promise<any> {
const supabase = await getSupabaseClient();
organizationSlug?: string,
): Promise<SupabaseProjectLogsResponse> {
const supabase = await getSupabaseClient({ organizationSlug });
// Build SQL query with optional timestamp filter
let sqlQuery = `
......@@ -238,7 +554,7 @@ LIMIT 1000`;
);
}
const jsonResponse = await response.json();
const jsonResponse: SupabaseProjectLogsResponse = await response.json();
logger.info(`Received ${jsonResponse.result?.length || 0} logs`);
return jsonResponse;
......@@ -247,15 +563,17 @@ LIMIT 1000`;
export async function executeSupabaseSql({
supabaseProjectId,
query,
organizationSlug,
}: {
supabaseProjectId: string;
query: string;
organizationSlug: string | null;
}): Promise<string> {
if (IS_TEST_BUILD) {
return "{}";
}
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
const result = await supabase.runQuery(supabaseProjectId, query);
return JSON.stringify(result);
}
......@@ -263,14 +581,16 @@ export async function executeSupabaseSql({
export async function deleteSupabaseFunction({
supabaseProjectId,
functionName,
organizationSlug,
}: {
supabaseProjectId: string;
functionName: string;
organizationSlug: string | null;
}): Promise<void> {
logger.info(
`Deleting Supabase function: ${functionName} from project: ${supabaseProjectId}`,
);
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
await supabase.deleteFunction(supabaseProjectId, functionName);
logger.info(
`Deleted Supabase function: ${functionName} from project: ${supabaseProjectId}`,
......@@ -279,17 +599,11 @@ export async function deleteSupabaseFunction({
export async function listSupabaseBranches({
supabaseProjectId,
organizationSlug,
}: {
supabaseProjectId: string;
}): Promise<
Array<{
id: string;
name: string;
is_default: boolean;
project_ref: string;
parent_project_ref: string;
}>
> {
organizationSlug: string | null;
}): Promise<SupabaseProjectBranch[]> {
if (IS_TEST_BUILD) {
return [
{
......@@ -311,7 +625,7 @@ export async function listSupabaseBranches({
}
logger.info(`Listing Supabase branches for project: ${supabaseProjectId}`);
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
const response = await fetch(
`https://api.supabase.com/v1/projects/${supabaseProjectId}/branches`,
......@@ -328,7 +642,7 @@ export async function listSupabaseBranches({
}
logger.info(`Listed Supabase branches for project: ${supabaseProjectId}`);
const jsonResponse = await response.json();
const jsonResponse: SupabaseProjectBranch[] = await response.json();
return jsonResponse;
}
......@@ -341,11 +655,13 @@ export async function deploySupabaseFunction({
functionName,
appPath,
bundleOnly = false,
organizationSlug,
}: {
supabaseProjectId: string;
functionName: string;
appPath: string;
bundleOnly?: boolean;
organizationSlug: string | null;
}): Promise<DeployedFunctionResponse> {
logger.info(
`Deploying Supabase function: ${functionName} to project: ${supabaseProjectId}`,
......@@ -387,7 +703,7 @@ export async function deploySupabaseFunction({
});
// 5) Prepare multipart form-data
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
const formData = new FormData();
// Metadata: instruct Supabase to use our import map
......@@ -437,15 +753,17 @@ export async function deploySupabaseFunction({
export async function bulkUpdateFunctions({
supabaseProjectId,
functions,
organizationSlug,
}: {
supabaseProjectId: string;
functions: DeployedFunctionResponse[];
organizationSlug: string | null;
}): Promise<void> {
logger.info(
`Bulk updating ${functions.length} functions for project: ${supabaseProjectId}`,
);
const supabase = await getSupabaseClient();
const supabase = await getSupabaseClient({ organizationSlug });
const response = await fetch(
`https://api.supabase.com/v1/projects/${encodeURIComponent(supabaseProjectId)}/functions`,
......
import { writeSettings } from "../main/settings";
import { readSettings, writeSettings } from "../main/settings";
import { listSupabaseOrganizations } from "./supabase_management_client";
import log from "electron-log";
export function handleSupabaseOAuthReturn({
token,
refreshToken,
expiresIn,
}: {
const logger = log.scope("supabase_return_handler");
export interface SupabaseOAuthReturnParams {
token: string;
refreshToken: string;
expiresIn: number;
}) {
writeSettings({
supabase: {
accessToken: {
value: token,
}
/**
* Handles OAuth return by storing organization credentials.
* If exactly one organization is found, it's stored in the organizations map.
* Otherwise, it falls back to legacy fields.
*/
export async function handleSupabaseOAuthReturn({
token,
refreshToken,
expiresIn,
}: SupabaseOAuthReturnParams) {
const settings = readSettings();
let orgs: any[] = [];
let errorOccurred = false;
try {
orgs = await listSupabaseOrganizations(token);
} catch (error) {
logger.error("Error listing Supabase organizations:", error);
errorOccurred = true;
}
if (!errorOccurred && orgs.length > 0) {
if (orgs.length > 1) {
logger.warn(
"Multiple Supabase organizations found unexpectedly, using the first one",
);
}
const organizationSlug = orgs[0].slug;
const existingOrgs = settings.supabase?.organizations ?? {};
writeSettings({
supabase: {
...settings.supabase,
organizations: {
...existingOrgs,
[organizationSlug]: {
accessToken: {
value: token,
},
refreshToken: {
value: refreshToken,
},
expiresIn,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
},
},
refreshToken: {
value: refreshToken,
});
} else {
// Fallback to legacy fields
writeSettings({
supabase: {
...settings.supabase,
accessToken: {
value: token,
},
refreshToken: {
value: refreshToken,
},
expiresIn,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
expiresIn,
tokenTimestamp: Math.floor(Date.now() / 1000),
},
});
});
}
}
......@@ -81,9 +81,11 @@ export function extractFunctionNameFromPath(filePath: string): string {
export async function deployAllSupabaseFunctions({
appPath,
supabaseProjectId,
supabaseOrganizationSlug,
}: {
appPath: string;
supabaseProjectId: string;
supabaseOrganizationSlug: string | null;
}): Promise<string[]> {
const functionsDir = path.join(appPath, "supabase", "functions");
......@@ -139,6 +141,7 @@ export async function deployAllSupabaseFunctions({
logger.info(`Bundling function: ${functionName}`);
const result = await deploySupabaseFunction({
supabaseProjectId,
organizationSlug: supabaseOrganizationSlug,
functionName,
appPath,
bundleOnly: true,
......@@ -172,6 +175,7 @@ export async function deployAllSupabaseFunctions({
await bulkUpdateFunctions({
supabaseProjectId,
functions: successfulDeploys,
organizationSlug: supabaseOrganizationSlug,
});
logger.info(
`Successfully activated ${successfulDeploys.length} functions`,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论