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: ...@@ -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`. - 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. - 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 ## General guidance
- Favor descriptive module/function names that mirror IPC channel semantics. - 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 @@ ...@@ -134,6 +134,13 @@
"when": 1766124364939, "when": 1766124364939,
"tag": "0018_skinny_ezekiel", "tag": "0018_skinny_ezekiel",
"breakpoints": true "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. ...@@ -5,3 +5,4 @@ This message simulates being close to the model's context window limit.
import { atom } from "jotai"; 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 // Define atom for storing the list of Supabase projects
export const supabaseProjectsAtom = atom<any[]>([]); export const supabaseProjectsAtom = atom<SupabaseProject[]>([]);
export const supabaseBranchesAtom = atom<SupabaseBranch[]>([]); export const supabaseBranchesAtom = atom<SupabaseBranch[]>([]);
// Define atom for tracking loading state // Define atom for tracking loading state
......
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
// We might need a Supabase icon here, but for now, let's use a generic one or text. // 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 { 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 { useSettings } from "@/hooks/useSettings";
import { useSupabase } from "@/hooks/useSupabase";
import { showSuccess, showError } from "@/lib/toast"; import { showSuccess, showError } from "@/lib/toast";
import { isSupabaseConnected } from "@/lib/schemas";
export function SupabaseIntegration() { export function SupabaseIntegration() {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { organizations, loadOrganizations, deleteOrganization } =
useSupabase();
const [isDisconnecting, setIsDisconnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false);
const handleDisconnectFromSupabase = async () => { useEffect(() => {
loadOrganizations();
}, [loadOrganizations]);
const handleDisconnectAllFromSupabase = async () => {
setIsDisconnecting(true); setIsDisconnecting(true);
try { try {
// Clear the entire supabase object in settings // Clear the entire supabase object in settings (including all organizations)
const result = await updateSettings({ const result = await updateSettings({
supabase: undefined, supabase: undefined,
// Also disable the migration setting on disconnect // Also disable the migration setting on disconnect
enableSupabaseWriteSqlMigration: false, enableSupabaseWriteSqlMigration: false,
}); });
if (result) { if (result) {
showSuccess("Successfully disconnected from Supabase"); showSuccess("Successfully disconnected all Supabase organizations");
await loadOrganizations();
} else { } else {
showError("Failed to disconnect from Supabase"); showError("Failed to disconnect from Supabase");
} }
...@@ -35,6 +44,15 @@ export function SupabaseIntegration() { ...@@ -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) => { const handleMigrationSettingChange = async (enabled: boolean) => {
try { try {
await updateSettings({ await updateSettings({
...@@ -46,8 +64,8 @@ export function SupabaseIntegration() { ...@@ -46,8 +64,8 @@ export function SupabaseIntegration() {
} }
}; };
// Check if there's any Supabase accessToken to determine connection status // Check if there are any connected organizations
const isConnected = !!settings?.supabase?.accessToken; const isConnected = isSupabaseConnected(settings);
if (!isConnected) { if (!isConnected) {
return null; return null;
...@@ -61,20 +79,53 @@ export function SupabaseIntegration() { ...@@ -61,20 +79,53 @@ export function SupabaseIntegration() {
Supabase Integration Supabase Integration
</h3> </h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <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> </p>
</div> </div>
<Button <Button
onClick={handleDisconnectFromSupabase} onClick={handleDisconnectAllFromSupabase}
variant="destructive" variant="destructive"
size="sm" size="sm"
disabled={isDisconnecting} disabled={isDisconnecting}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
{isDisconnecting ? "Disconnecting..." : "Disconnect from Supabase"} {isDisconnecting ? "Disconnecting..." : "Disconnect All"}
<DatabaseZap className="h-4 w-4" /> <DatabaseZap className="h-4 w-4" />
</Button> </Button>
</div> </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="mt-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Switch <Switch
......
...@@ -113,22 +113,23 @@ export function PreviewPanel() { ...@@ -113,22 +113,23 @@ export function PreviewPanel() {
// Load edge logs if app has Supabase project configured // Load edge logs if app has Supabase project configured
useEffect(() => { useEffect(() => {
const projectId = app?.supabaseProjectId; const projectId = app?.supabaseProjectId;
const organizationSlug = app?.supabaseOrganizationSlug ?? undefined;
if (!projectId) return; if (!projectId) return;
// Load logs immediately // Load logs immediately
loadEdgeLogs(projectId).catch((error) => { loadEdgeLogs(projectId, organizationSlug).catch((error) => {
console.error("Failed to load edge logs:", error); console.error("Failed to load edge logs:", error);
}); });
// Poll for new logs every 5 seconds // Poll for new logs every 5 seconds
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
loadEdgeLogs(projectId).catch((error) => { loadEdgeLogs(projectId, organizationSlug).catch((error) => {
console.error("Failed to load edge logs:", error); console.error("Failed to load edge logs:", error);
}); });
}, 5000); }, 5000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [app?.supabaseProjectId, loadEdgeLogs]); }, [app?.supabaseProjectId, app?.supabaseOrganizationSlug, loadEdgeLogs]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
......
...@@ -43,6 +43,8 @@ export const apps = sqliteTable("apps", { ...@@ -43,6 +43,8 @@ export const apps = sqliteTable("apps", {
// This is only used for display purposes but is NOT used for any actual // This is only used for display purposes but is NOT used for any actual
// supabase management logic. // supabase management logic.
supabaseParentProjectId: text("supabase_parent_project_id"), supabaseParentProjectId: text("supabase_parent_project_id"),
// Supabase organization slug for credential lookup
supabaseOrganizationSlug: text("supabase_organization_slug"),
neonProjectId: text("neon_project_id"), neonProjectId: text("neon_project_id"),
neonDevelopmentBranchId: text("neon_development_branch_id"), neonDevelopmentBranchId: text("neon_development_branch_id"),
neonPreviewBranchId: text("neon_preview_branch_id"), neonPreviewBranchId: text("neon_preview_branch_id"),
......
import { useCallback } from "react"; import { useCallback } from "react";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import {
supabaseOrganizationsAtom,
supabaseProjectsAtom, supabaseProjectsAtom,
supabaseBranchesAtom, supabaseBranchesAtom,
supabaseLoadingAtom, supabaseLoadingAtom,
...@@ -10,9 +11,13 @@ import { ...@@ -10,9 +11,13 @@ import {
} from "@/atoms/supabaseAtoms"; } from "@/atoms/supabaseAtoms";
import { appConsoleEntriesAtom, selectedAppIdAtom } from "@/atoms/appAtoms"; import { appConsoleEntriesAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { SetSupabaseAppProjectParams } from "@/ipc/ipc_types"; import {
SetSupabaseAppProjectParams,
DeleteSupabaseOrganizationParams,
} from "@/ipc/ipc_types";
export function useSupabase() { export function useSupabase() {
const [organizations, setOrganizations] = useAtom(supabaseOrganizationsAtom);
const [projects, setProjects] = useAtom(supabaseProjectsAtom); const [projects, setProjects] = useAtom(supabaseProjectsAtom);
const [branches, setBranches] = useAtom(supabaseBranchesAtom); const [branches, setBranches] = useAtom(supabaseBranchesAtom);
const [loading, setLoading] = useAtom(supabaseLoadingAtom); const [loading, setLoading] = useAtom(supabaseLoadingAtom);
...@@ -27,12 +32,52 @@ export function useSupabase() { ...@@ -27,12 +32,52 @@ export function useSupabase() {
const ipcClient = IpcClient.getInstance(); 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 () => { const loadProjects = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const projectList = await ipcClient.listSupabaseProjects(); const projectList = await ipcClient.listAllSupabaseProjects();
setProjects(projectList); setProjects(projectList);
setError(null); setError(null);
} catch (error) { } catch (error) {
...@@ -47,10 +92,13 @@ export function useSupabase() { ...@@ -47,10 +92,13 @@ export function useSupabase() {
* Load branches for a Supabase project * Load branches for a Supabase project
*/ */
const loadBranches = useCallback( const loadBranches = useCallback(
async (projectId: string) => { async (projectId: string, organizationSlug?: string) => {
setLoading(true); setLoading(true);
try { try {
const list = await ipcClient.listSupabaseBranches({ projectId }); const list = await ipcClient.listSupabaseBranches({
projectId,
organizationSlug: organizationSlug ?? null,
});
setBranches(Array.isArray(list) ? list : []); setBranches(Array.isArray(list) ? list : []);
setError(null); setError(null);
} catch (error) { } catch (error) {
...@@ -108,7 +156,7 @@ export function useSupabase() { ...@@ -108,7 +156,7 @@ export function useSupabase() {
* Uses timestamp tracking to only fetch new logs on subsequent calls * Uses timestamp tracking to only fetch new logs on subsequent calls
*/ */
const loadEdgeLogs = useCallback( const loadEdgeLogs = useCallback(
async (projectId: string) => { async (projectId: string, organizationSlug?: string) => {
if (!selectedAppId) return; if (!selectedAppId) return;
// Use last timestamp if available, otherwise fetch logs from the past 10 minutes // Use last timestamp if available, otherwise fetch logs from the past 10 minutes
...@@ -122,6 +170,7 @@ export function useSupabase() { ...@@ -122,6 +170,7 @@ export function useSupabase() {
projectId, projectId,
timestampStart, timestampStart,
appId: selectedAppId, appId: selectedAppId,
organizationSlug: organizationSlug ?? null,
}); });
if (logs.length === 0) { if (logs.length === 0) {
...@@ -178,11 +227,14 @@ export function useSupabase() { ...@@ -178,11 +227,14 @@ export function useSupabase() {
); );
return { return {
organizations,
projects, projects,
branches, branches,
loading, loading,
error, error,
selectedProject, selectedProject,
loadOrganizations,
deleteOrganization,
loadProjects, loadProjects,
loadBranches, loadBranches,
loadEdgeLogs, loadEdgeLogs,
......
...@@ -723,9 +723,16 @@ export function registerAppHandlers() { ...@@ -723,9 +723,16 @@ export function registerAppHandlers() {
let supabaseProjectName: string | null = null; let supabaseProjectName: string | null = null;
const settings = readSettings(); 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( supabaseProjectName = await getSupabaseProjectName(
app.supabaseParentProjectId || app.supabaseProjectId, app.supabaseParentProjectId || app.supabaseProjectId,
app.supabaseOrganizationSlug ?? undefined,
); );
} }
...@@ -1073,6 +1080,7 @@ export function registerAppHandlers() { ...@@ -1073,6 +1080,7 @@ export function registerAppHandlers() {
const deployErrors = await deployAllSupabaseFunctions({ const deployErrors = await deployAllSupabaseFunctions({
appPath, appPath,
supabaseProjectId: app.supabaseProjectId, supabaseProjectId: app.supabaseProjectId,
supabaseOrganizationSlug: app.supabaseOrganizationSlug ?? null,
}); });
if (deployErrors.length > 0) { if (deployErrors.length > 0) {
return { return {
...@@ -1096,6 +1104,7 @@ export function registerAppHandlers() { ...@@ -1096,6 +1104,7 @@ export function registerAppHandlers() {
supabaseProjectId: app.supabaseProjectId, supabaseProjectId: app.supabaseProjectId,
functionName, functionName,
appPath, appPath,
organizationSlug: app.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
logger.error( logger.error(
......
...@@ -80,7 +80,7 @@ import { inArray } from "drizzle-orm"; ...@@ -80,7 +80,7 @@ import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference"; import { replacePromptReference } from "../utils/replacePromptReference";
import { mcpManager } from "../utils/mcp_manager"; import { mcpManager } from "../utils/mcp_manager";
import z from "zod"; 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 { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils"; import { getCurrentCommitHash } from "../utils/git_utils";
import { import {
...@@ -649,7 +649,7 @@ ${componentSnippet} ...@@ -649,7 +649,7 @@ ${componentSnippet}
if ( if (
updatedChat.app?.supabaseProjectId && updatedChat.app?.supabaseProjectId &&
settings.supabase?.accessToken?.value isSupabaseConnected(settings)
) { ) {
systemPrompt += systemPrompt +=
"\n\n" + "\n\n" +
...@@ -660,6 +660,8 @@ ${componentSnippet} ...@@ -660,6 +660,8 @@ ${componentSnippet}
? "" ? ""
: await getSupabaseContext({ : await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId, supabaseProjectId: updatedChat.app.supabaseProjectId,
organizationSlug:
updatedChat.app.supabaseOrganizationSlug ?? null,
})); }));
} else if ( } else if (
// Neon projects don't need Supabase. // Neon projects don't need Supabase.
...@@ -954,6 +956,8 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -954,6 +956,8 @@ This conversation includes one or more image attachments. When the user uploads
) { ) {
const supabaseClientCode = await getSupabaseClientCode({ const supabaseClientCode = await getSupabaseClientCode({
projectId: updatedChat.app?.supabaseProjectId, projectId: updatedChat.app?.supabaseProjectId,
organizationSlug:
updatedChat.app?.supabaseOrganizationSlug ?? null,
}); });
fullResponse = fullResponse.replace( fullResponse = fullResponse.replace(
"$$SUPABASE_CLIENT_CODE$$", "$$SUPABASE_CLIENT_CODE$$",
......
...@@ -713,6 +713,7 @@ async function handleCloneRepoFromUrl( ...@@ -713,6 +713,7 @@ async function handleCloneRepoFromUrl(
...newApp, ...newApp,
files: [], files: [],
supabaseProjectName: null, supabaseProjectName: null,
supabaseOrganizationSlug: null,
vercelTeamSlug: null, vercelTeamSlug: null,
}, },
hasAiRules, hasAiRules,
......
...@@ -3,19 +3,28 @@ import { db } from "../../db"; ...@@ -3,19 +3,28 @@ import { db } from "../../db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { apps } from "../../db/schema"; import { apps } from "../../db/schema";
import { import {
getSupabaseClient, getSupabaseClientForOrganization,
listSupabaseBranches, listSupabaseBranches,
getSupabaseProjectLogs, getSupabaseProjectLogs,
getOrganizationDetails,
getOrganizationMembers,
type SupabaseProjectLog,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { extractFunctionName } from "../../supabase_admin/supabase_utils"; import { extractFunctionName } from "../../supabase_admin/supabase_utils";
import { import {
createLoggedHandler, createLoggedHandler,
createTestOnlyLoggedHandler, createTestOnlyLoggedHandler,
} from "./safe_handle"; } from "./safe_handle";
import { handleSupabaseOAuthReturn } from "../../supabase_admin/supabase_return_handler";
import { safeSend } from "../utils/safe_sender"; 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"; import type { ConsoleEntry } from "../../atoms/appAtoms";
const logger = log.scope("supabase_handlers"); const logger = log.scope("supabase_handlers");
...@@ -23,9 +32,105 @@ const handle = createLoggedHandler(logger); ...@@ -23,9 +32,105 @@ const handle = createLoggedHandler(logger);
const testOnlyHandle = createTestOnlyLoggedHandler(logger); const testOnlyHandle = createTestOnlyLoggedHandler(logger);
export function registerSupabaseHandlers() { export function registerSupabaseHandlers() {
handle("supabase:list-projects", async () => { // List all connected Supabase organizations with details
const supabase = await getSupabaseClient(); handle(
return supabase.getProjects(); "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) // List branches for a Supabase project (database branches)
...@@ -33,10 +138,14 @@ export function registerSupabaseHandlers() { ...@@ -33,10 +138,14 @@ export function registerSupabaseHandlers() {
"supabase:list-branches", "supabase:list-branches",
async ( async (
_, _,
{ projectId }: { projectId: string }, {
projectId,
organizationSlug,
}: { projectId: string; organizationSlug?: string },
): Promise<Array<SupabaseBranch>> => { ): Promise<Array<SupabaseBranch>> => {
const branches = await listSupabaseBranches({ const branches = await listSupabaseBranches({
supabaseProjectId: projectId, supabaseProjectId: projectId,
organizationSlug: organizationSlug ?? null,
}); });
return branches.map((branch) => ({ return branches.map((branch) => ({
id: branch.id, id: branch.id,
...@@ -57,9 +166,19 @@ export function registerSupabaseHandlers() { ...@@ -57,9 +166,19 @@ export function registerSupabaseHandlers() {
projectId, projectId,
timestampStart, timestampStart,
appId, appId,
}: { projectId: string; timestampStart?: number; appId: number }, organizationSlug,
}: {
projectId: string;
timestampStart?: number;
appId: number;
organizationSlug: string | null;
},
): Promise<Array<ConsoleEntry>> => { ): Promise<Array<ConsoleEntry>> => {
const response = await getSupabaseProjectLogs(projectId, timestampStart); const response = await getSupabaseProjectLogs(
projectId,
timestampStart,
organizationSlug ?? undefined,
);
if (response.error) { if (response.error) {
const errorMsg = const errorMsg =
...@@ -72,7 +191,7 @@ export function registerSupabaseHandlers() { ...@@ -72,7 +191,7 @@ export function registerSupabaseHandlers() {
const rawLogs = response.result || []; const rawLogs = response.result || [];
// Transform to ConsoleEntry format // Transform to ConsoleEntry format
return rawLogs.map((log: any) => { return rawLogs.map((log: SupabaseProjectLog) => {
const metadata = log.metadata?.[0] || {}; const metadata = log.metadata?.[0] || {};
const level = metadata.level || "info"; const level = metadata.level || "info";
const eventMessage = log.event_message || ""; const eventMessage = log.event_message || "";
...@@ -96,18 +215,24 @@ export function registerSupabaseHandlers() { ...@@ -96,18 +215,24 @@ export function registerSupabaseHandlers() {
"supabase:set-app-project", "supabase:set-app-project",
async ( async (
_, _,
{ projectId, appId, parentProjectId }: SetSupabaseAppProjectParams, {
projectId,
appId,
parentProjectId,
organizationSlug,
}: SetSupabaseAppProjectParams,
) => { ) => {
await db await db
.update(apps) .update(apps)
.set({ .set({
supabaseProjectId: projectId, supabaseProjectId: projectId,
supabaseParentProjectId: parentProjectId, supabaseParentProjectId: parentProjectId,
supabaseOrganizationSlug: organizationSlug,
}) })
.where(eq(apps.id, appId)); .where(eq(apps.id, appId));
logger.info( 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() { ...@@ -116,7 +241,11 @@ export function registerSupabaseHandlers() {
handle("supabase:unset-app-project", async (_, { app }: { app: number }) => { handle("supabase:unset-app-project", async (_, { app }: { app: number }) => {
await db await db
.update(apps) .update(apps)
.set({ supabaseProjectId: null, supabaseParentProjectId: null }) .set({
supabaseProjectId: null,
supabaseParentProjectId: null,
supabaseOrganizationSlug: null,
})
.where(eq(apps.id, app)); .where(eq(apps.id, app));
logger.info(`Removed Supabase project association for app ${app}`); logger.info(`Removed Supabase project association for app ${app}`);
...@@ -128,14 +257,33 @@ export function registerSupabaseHandlers() { ...@@ -128,14 +257,33 @@ export function registerSupabaseHandlers() {
event, event,
{ appId, fakeProjectId }: { appId: number; fakeProjectId: string }, { appId, fakeProjectId }: { appId: number; fakeProjectId: string },
) => { ) => {
// Call handleSupabaseOAuthReturn with fake data const fakeOrgId = "fake-org-id";
handleSupabaseOAuthReturn({
token: "fake-access-token", // Directly store fake credentials in the organizations map
refreshToken: "fake-refresh-token", // We don't call handleSupabaseOAuthReturn because it attempts a real API call
expiresIn: 3600, // 1 hour // 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( 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 // Set the supabase project for the currently selected app
...@@ -143,6 +291,7 @@ export function registerSupabaseHandlers() { ...@@ -143,6 +291,7 @@ export function registerSupabaseHandlers() {
.update(apps) .update(apps)
.set({ .set({
supabaseProjectId: fakeProjectId, supabaseProjectId: fakeProjectId,
supabaseOrganizationSlug: fakeOrgId,
}) })
.where(eq(apps.id, appId)); .where(eq(apps.id, appId));
logger.info( logger.info(
......
...@@ -76,6 +76,7 @@ export function registerTokenCountHandlers() { ...@@ -76,6 +76,7 @@ export function registerTokenCountHandlers() {
systemPrompt += "\n\n" + SUPABASE_AVAILABLE_SYSTEM_PROMPT; systemPrompt += "\n\n" + SUPABASE_AVAILABLE_SYSTEM_PROMPT;
supabaseContext = await getSupabaseContext({ supabaseContext = await getSupabaseContext({
supabaseProjectId: chat.app.supabaseProjectId, supabaseProjectId: chat.app.supabaseProjectId,
organizationSlug: chat.app.supabaseOrganizationSlug ?? null,
}); });
} else if ( } else if (
// Neon projects don't need Supabase. // Neon projects don't need Supabase.
......
...@@ -322,6 +322,7 @@ export function registerVersionHandlers() { ...@@ -322,6 +322,7 @@ export function registerVersionHandlers() {
const deployErrors = await deployAllSupabaseFunctions({ const deployErrors = await deployAllSupabaseFunctions({
appPath, appPath,
supabaseProjectId: app.supabaseProjectId, supabaseProjectId: app.supabaseProjectId,
supabaseOrganizationSlug: app.supabaseOrganizationSlug ?? null,
}); });
if (deployErrors.length > 0) { if (deployErrors.length > 0) {
......
...@@ -69,6 +69,9 @@ import type { ...@@ -69,6 +69,9 @@ import type {
CloneRepoParams, CloneRepoParams,
SupabaseBranch, SupabaseBranch,
SetSupabaseAppProjectParams, SetSupabaseAppProjectParams,
SupabaseOrganizationInfo,
SupabaseProject,
DeleteSupabaseOrganizationParams,
SelectNodeFolderResult, SelectNodeFolderResult,
ApplyVisualEditingChangesParams, ApplyVisualEditingChangesParams,
AnalyseComponentParams, AnalyseComponentParams,
...@@ -1041,12 +1044,29 @@ export class IpcClient { ...@@ -1041,12 +1044,29 @@ export class IpcClient {
// --- End Proposal Management --- // --- End Proposal Management ---
// --- Supabase 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: { public async listSupabaseBranches(params: {
projectId: string; projectId: string;
organizationSlug: string | null;
}): Promise<SupabaseBranch[]> { }): Promise<SupabaseBranch[]> {
return this.ipcRenderer.invoke("supabase:list-branches", params); return this.ipcRenderer.invoke("supabase:list-branches", params);
} }
...@@ -1055,6 +1075,7 @@ export class IpcClient { ...@@ -1055,6 +1075,7 @@ export class IpcClient {
projectId: string; projectId: string;
timestampStart?: number; timestampStart?: number;
appId: number; appId: number;
organizationSlug: string | null;
}): Promise<Array<ConsoleEntry>> { }): Promise<Array<ConsoleEntry>> {
return this.ipcRenderer.invoke("supabase:get-edge-logs", params); return this.ipcRenderer.invoke("supabase:get-edge-logs", params);
} }
......
...@@ -107,6 +107,7 @@ export interface App { ...@@ -107,6 +107,7 @@ export interface App {
supabaseProjectId: string | null; supabaseProjectId: string | null;
supabaseParentProjectId: string | null; supabaseParentProjectId: string | null;
supabaseProjectName: string | null; supabaseProjectName: string | null;
supabaseOrganizationSlug: string | null;
neonProjectId: string | null; neonProjectId: string | null;
neonDevelopmentBranchId: string | null; neonDevelopmentBranchId: string | null;
neonPreviewBranchId: string | null; neonPreviewBranchId: string | null;
...@@ -537,10 +538,34 @@ export interface SupabaseBranch { ...@@ -537,10 +538,34 @@ export interface SupabaseBranch {
parentProjectRef: string; 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 { export interface SetSupabaseAppProjectParams {
projectId: string; projectId: string;
parentProjectId?: string; parentProjectId?: string;
appId: number; appId: number;
organizationSlug: string | null;
}
export interface DeleteSupabaseOrganizationParams {
organizationSlug: string;
} }
// Supabase Logs // Supabase Logs
......
...@@ -185,6 +185,7 @@ export async function processFullResponseActions( ...@@ -185,6 +185,7 @@ export async function processFullResponseActions(
await executeSupabaseSql({ await executeSupabaseSql({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
query: query.content, query: query.content,
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
// Only write migration file if SQL execution succeeded // Only write migration file if SQL execution succeeded
...@@ -287,6 +288,7 @@ export async function processFullResponseActions( ...@@ -287,6 +288,7 @@ export async function processFullResponseActions(
await deleteSupabaseFunction({ await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(filePath), functionName: extractFunctionNameFromPath(filePath),
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -334,6 +336,7 @@ export async function processFullResponseActions( ...@@ -334,6 +336,7 @@ export async function processFullResponseActions(
await deleteSupabaseFunction({ await deleteSupabaseFunction({
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(tag.from), functionName: extractFunctionNameFromPath(tag.from),
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
warnings.push({ warnings.push({
...@@ -349,6 +352,7 @@ export async function processFullResponseActions( ...@@ -349,6 +352,7 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(tag.to), functionName: extractFunctionNameFromPath(tag.to),
appPath, appPath,
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -396,6 +400,8 @@ export async function processFullResponseActions( ...@@ -396,6 +400,8 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(filePath), functionName: extractFunctionNameFromPath(filePath),
appPath, appPath,
organizationSlug:
chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -466,6 +472,7 @@ export async function processFullResponseActions( ...@@ -466,6 +472,7 @@ export async function processFullResponseActions(
supabaseProjectId: chatWithApp.app.supabaseProjectId!, supabaseProjectId: chatWithApp.app.supabaseProjectId!,
functionName: extractFunctionNameFromPath(filePath), functionName: extractFunctionNameFromPath(filePath),
appPath, appPath,
organizationSlug: chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
errors.push({ errors.push({
...@@ -485,6 +492,8 @@ export async function processFullResponseActions( ...@@ -485,6 +492,8 @@ export async function processFullResponseActions(
const deployErrors = await deployAllSupabaseFunctions({ const deployErrors = await deployAllSupabaseFunctions({
appPath, appPath,
supabaseProjectId: chatWithApp.app.supabaseProjectId, supabaseProjectId: chatWithApp.app.supabaseProjectId,
supabaseOrganizationSlug:
chatWithApp.app.supabaseOrganizationSlug ?? null,
}); });
if (deployErrors.length > 0) { if (deployErrors.length > 0) {
for (const err of deployErrors) { for (const err of deployErrors) {
......
...@@ -155,7 +155,27 @@ export const GithubUserSchema = z.object({ ...@@ -155,7 +155,27 @@ export const GithubUserSchema = z.object({
}); });
export type GithubUser = z.infer<typeof GithubUserSchema>; 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({ 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(), accessToken: SecretSchema.optional(),
refreshToken: SecretSchema.optional(), refreshToken: SecretSchema.optional(),
expiresIn: z.number().optional(), expiresIn: z.number().optional(),
...@@ -301,6 +321,17 @@ export function hasDyadProKey(settings: UserSettings): boolean { ...@@ -301,6 +321,17 @@ export function hasDyadProKey(settings: UserSettings): boolean {
return !!settings.providerSettings?.auto?.apiKey?.value; 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 { export function isTurboEditsV2Enabled(settings: UserSettings): boolean {
return Boolean( return Boolean(
isDyadProEnabled(settings) && isDyadProEnabled(settings) &&
......
...@@ -308,7 +308,7 @@ app.on("open-url", (event, url) => { ...@@ -308,7 +308,7 @@ app.on("open-url", (event, url) => {
handleDeepLinkReturn(url); handleDeepLinkReturn(url);
}); });
function handleDeepLinkReturn(url: string) { async function handleDeepLinkReturn(url: string) {
// example url: "dyad://supabase-oauth-return?token=a&refreshToken=b" // example url: "dyad://supabase-oauth-return?token=a&refreshToken=b"
let parsed: URL; let parsed: URL;
try { try {
...@@ -361,7 +361,7 @@ function handleDeepLinkReturn(url: string) { ...@@ -361,7 +361,7 @@ function handleDeepLinkReturn(url: string) {
); );
return; return;
} }
handleSupabaseOAuthReturn({ token, refreshToken, expiresIn }); await handleSupabaseOAuthReturn({ token, refreshToken, expiresIn });
// Send message to renderer to trigger re-render // Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", { mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname, type: parsed.hostname,
......
...@@ -58,6 +58,7 @@ export function readSettings(): UserSettings { ...@@ -58,6 +58,7 @@ export function readSettings(): UserSettings {
}; };
const supabase = combinedSettings.supabase; const supabase = combinedSettings.supabase;
if (supabase) { if (supabase) {
// Decrypt legacy tokens (kept but ignored)
if (supabase.refreshToken) { if (supabase.refreshToken) {
const encryptionType = supabase.refreshToken.encryptionType; const encryptionType = supabase.refreshToken.encryptionType;
if (encryptionType) { if (encryptionType) {
...@@ -76,6 +77,30 @@ export function readSettings(): UserSettings { ...@@ -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; const neon = combinedSettings.neon;
if (neon) { if (neon) {
...@@ -163,6 +188,7 @@ export function writeSettings(settings: Partial<UserSettings>): void { ...@@ -163,6 +188,7 @@ export function writeSettings(settings: Partial<UserSettings>): void {
); );
} }
if (newSettings.supabase) { if (newSettings.supabase) {
// Encrypt legacy tokens (kept for backwards compat)
if (newSettings.supabase.accessToken) { if (newSettings.supabase.accessToken) {
newSettings.supabase.accessToken = encrypt( newSettings.supabase.accessToken = encrypt(
newSettings.supabase.accessToken.value, newSettings.supabase.accessToken.value,
...@@ -173,6 +199,18 @@ export function writeSettings(settings: Partial<UserSettings>): void { ...@@ -173,6 +199,18 @@ export function writeSettings(settings: Partial<UserSettings>): void {
newSettings.supabase.refreshToken.value, 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) {
if (newSettings.neon.accessToken) { if (newSettings.neon.accessToken) {
......
...@@ -78,7 +78,9 @@ const validInvokeChannels = [ ...@@ -78,7 +78,9 @@ const validInvokeChannels = [
"approve-proposal", "approve-proposal",
"reject-proposal", "reject-proposal",
"get-system-debug-info", "get-system-debug-info",
"supabase:list-projects", "supabase:list-organizations",
"supabase:delete-organization",
"supabase:list-all-projects",
"supabase:list-branches", "supabase:list-branches",
"supabase:get-edge-logs", "supabase:get-edge-logs",
"supabase:set-app-project", "supabase:set-app-project",
......
...@@ -147,6 +147,7 @@ export async function handleLocalAgentStream( ...@@ -147,6 +147,7 @@ export async function handleLocalAgentStream(
appPath, appPath,
chatId: chat.id, chatId: chat.id,
supabaseProjectId: chat.app.supabaseProjectId, supabaseProjectId: chat.app.supabaseProjectId,
supabaseOrganizationSlug: chat.app.supabaseOrganizationSlug,
messageId: placeholderMessageId, messageId: placeholderMessageId,
isSharedModulesChanged: false, isSharedModulesChanged: false,
onXmlStream: (accumulatedXml: string) => { onXmlStream: (accumulatedXml: string) => {
......
...@@ -25,7 +25,10 @@ export interface FileOperationResult { ...@@ -25,7 +25,10 @@ export interface FileOperationResult {
export async function deployAllFunctionsIfNeeded( export async function deployAllFunctionsIfNeeded(
ctx: Pick< ctx: Pick<
AgentContext, AgentContext,
"appPath" | "supabaseProjectId" | "isSharedModulesChanged" | "appPath"
| "supabaseProjectId"
| "supabaseOrganizationSlug"
| "isSharedModulesChanged"
>, >,
): Promise<FileOperationResult> { ): Promise<FileOperationResult> {
if (!ctx.supabaseProjectId || !ctx.isSharedModulesChanged) { if (!ctx.supabaseProjectId || !ctx.isSharedModulesChanged) {
...@@ -37,6 +40,7 @@ export async function deployAllFunctionsIfNeeded( ...@@ -37,6 +40,7 @@ export async function deployAllFunctionsIfNeeded(
const deployErrors = await deployAllSupabaseFunctions({ const deployErrors = await deployAllSupabaseFunctions({
appPath: ctx.appPath, appPath: ctx.appPath,
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
supabaseOrganizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
if (deployErrors.length > 0) { if (deployErrors.length > 0) {
......
...@@ -196,6 +196,7 @@ async function processArgPlaceholders<T extends Record<string, any>>( ...@@ -196,6 +196,7 @@ async function processArgPlaceholders<T extends Record<string, any>>(
// Fetch the replacement value once // Fetch the replacement value once
const supabaseClientCode = await getSupabaseClientCode({ const supabaseClientCode = await getSupabaseClientCode({
projectId: ctx.supabaseProjectId, projectId: ctx.supabaseProjectId,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
// Process all string values in args // Process all string values in args
......
...@@ -64,6 +64,7 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> = ...@@ -64,6 +64,7 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> =
await deleteSupabaseFunction({ await deleteSupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.path), functionName: getFunctionNameFromPath(args.path),
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
return `File deleted, but failed to delete Supabase function: ${error}`; return `File deleted, but failed to delete Supabase function: ${error}`;
......
...@@ -38,6 +38,7 @@ export const executeSqlTool: ToolDefinition<z.infer<typeof executeSqlSchema>> = ...@@ -38,6 +38,7 @@ export const executeSqlTool: ToolDefinition<z.infer<typeof executeSqlSchema>> =
await executeSupabaseSql({ await executeSupabaseSql({
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
query: args.query, query: args.query,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
// Write migration file if enabled // Write migration file if enabled
......
...@@ -29,6 +29,7 @@ export const getDatabaseSchemaTool: ToolDefinition< ...@@ -29,6 +29,7 @@ export const getDatabaseSchemaTool: ToolDefinition<
const schema = await getSupabaseContext({ const schema = await getSupabaseContext({
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
return schema || ""; return schema || "";
......
...@@ -73,6 +73,7 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> = ...@@ -73,6 +73,7 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
await deleteSupabaseFunction({ await deleteSupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.from), functionName: getFunctionNameFromPath(args.from),
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
logger.warn( logger.warn(
...@@ -87,6 +88,7 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> = ...@@ -87,6 +88,7 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
functionName: getFunctionNameFromPath(args.to), functionName: getFunctionNameFromPath(args.to),
appPath: ctx.appPath, appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
return `File renamed, but failed to deploy Supabase function: ${error}`; return `File renamed, but failed to deploy Supabase function: ${error}`;
......
...@@ -98,6 +98,7 @@ export const searchReplaceTool: ToolDefinition< ...@@ -98,6 +98,7 @@ export const searchReplaceTool: ToolDefinition<
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)), functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath, appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
return `Search-replace applied, but failed to deploy Supabase function: ${error}`; return `Search-replace applied, but failed to deploy Supabase function: ${error}`;
......
...@@ -27,9 +27,10 @@ export interface AgentContext { ...@@ -27,9 +27,10 @@ export interface AgentContext {
event: IpcMainInvokeEvent; event: IpcMainInvokeEvent;
appPath: string; appPath: string;
chatId: number; chatId: number;
supabaseProjectId?: string | null; supabaseProjectId: string | null;
messageId?: number; supabaseOrganizationSlug: string | null;
isSharedModulesChanged?: boolean; messageId: number;
isSharedModulesChanged: boolean;
chatSummary?: string; chatSummary?: string;
/** /**
* Streams accumulated XML to UI without persisting to DB (for live preview). * Streams accumulated XML to UI without persisting to DB (for live preview).
......
...@@ -66,6 +66,7 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = { ...@@ -66,6 +66,7 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = {
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)), functionName: path.basename(path.dirname(args.path)),
appPath: ctx.appPath, appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
}); });
} catch (error) { } catch (error) {
return `File written, but failed to deploy Supabase function: ${error}`; return `File written, but failed to deploy Supabase function: ${error}`;
......
...@@ -2,12 +2,18 @@ import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; ...@@ -2,12 +2,18 @@ import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { getSupabaseClient } from "./supabase_management_client"; import { getSupabaseClient } from "./supabase_management_client";
import { SUPABASE_SCHEMA_QUERY } from "./supabase_schema_query"; 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) { if (IS_TEST_BUILD) {
return "test-publishable-key"; return "test-publishable-key";
} }
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient({ organizationSlug });
let keys; let keys;
try { try {
keys = await supabase.getProjectApiKeys(projectId); keys = await supabase.getProjectApiKeys(projectId);
...@@ -33,10 +39,15 @@ async function getPublishableKey({ projectId }: { projectId: string }) { ...@@ -33,10 +39,15 @@ async function getPublishableKey({ projectId }: { projectId: string }) {
} }
export const getSupabaseClientCode = async function ({ export const getSupabaseClientCode = async function ({
projectId, projectId,
organizationSlug,
}: { }: {
projectId: string; projectId: string;
organizationSlug: string | null;
}) { }) {
const publishableKey = await getPublishableKey({ projectId }); const publishableKey = await getPublishableKey({
projectId,
organizationSlug,
});
return ` return `
// This file is automatically generated. Do not edit it directly. // This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
...@@ -52,8 +63,10 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);`; ...@@ -52,8 +63,10 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);`;
export async function getSupabaseContext({ export async function getSupabaseContext({
supabaseProjectId, supabaseProjectId,
organizationSlug,
}: { }: {
supabaseProjectId: string; supabaseProjectId: string;
organizationSlug: string | null;
}) { }) {
if (IS_TEST_BUILD) { if (IS_TEST_BUILD) {
if (supabaseProjectId === "test-branch-project-id") { if (supabaseProjectId === "test-branch-project-id") {
...@@ -62,9 +75,10 @@ export async function getSupabaseContext({ ...@@ -62,9 +75,10 @@ export async function getSupabaseContext({
return "[[TEST_BUILD_SUPABASE_CONTEXT]]"; return "[[TEST_BUILD_SUPABASE_CONTEXT]]";
} }
const supabase = await getSupabaseClient(); const supabase = await getSupabaseClient({ organizationSlug });
const publishableKey = await getPublishableKey({ const publishableKey = await getPublishableKey({
projectId: supabaseProjectId, projectId: supabaseProjectId,
organizationSlug,
}); });
const schema = await supabase.runQuery( const schema = await supabase.runQuery(
supabaseProjectId, 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({ const logger = log.scope("supabase_return_handler");
token,
refreshToken, export interface SupabaseOAuthReturnParams {
expiresIn,
}: {
token: string; token: string;
refreshToken: string; refreshToken: string;
expiresIn: number; expiresIn: number;
}) { }
writeSettings({
supabase: { /**
accessToken: { * Handles OAuth return by storing organization credentials.
value: token, * 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 { ...@@ -81,9 +81,11 @@ export function extractFunctionNameFromPath(filePath: string): string {
export async function deployAllSupabaseFunctions({ export async function deployAllSupabaseFunctions({
appPath, appPath,
supabaseProjectId, supabaseProjectId,
supabaseOrganizationSlug,
}: { }: {
appPath: string; appPath: string;
supabaseProjectId: string; supabaseProjectId: string;
supabaseOrganizationSlug: string | null;
}): Promise<string[]> { }): Promise<string[]> {
const functionsDir = path.join(appPath, "supabase", "functions"); const functionsDir = path.join(appPath, "supabase", "functions");
...@@ -139,6 +141,7 @@ export async function deployAllSupabaseFunctions({ ...@@ -139,6 +141,7 @@ export async function deployAllSupabaseFunctions({
logger.info(`Bundling function: ${functionName}`); logger.info(`Bundling function: ${functionName}`);
const result = await deploySupabaseFunction({ const result = await deploySupabaseFunction({
supabaseProjectId, supabaseProjectId,
organizationSlug: supabaseOrganizationSlug,
functionName, functionName,
appPath, appPath,
bundleOnly: true, bundleOnly: true,
...@@ -172,6 +175,7 @@ export async function deployAllSupabaseFunctions({ ...@@ -172,6 +175,7 @@ export async function deployAllSupabaseFunctions({
await bulkUpdateFunctions({ await bulkUpdateFunctions({
supabaseProjectId, supabaseProjectId,
functions: successfulDeploys, functions: successfulDeploys,
organizationSlug: supabaseOrganizationSlug,
}); });
logger.info( logger.info(
`Successfully activated ${successfulDeploys.length} functions`, `Successfully activated ${successfulDeploys.length} functions`,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论