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
差异被折叠。
......@@ -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
......
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,
......
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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论