فهرست منبع

neon frontend

Feroze Mohideen 2 سال پیش
والد
کامیت
6827fd7c97
25فایلهای تغییر یافته به همراه856 افزوده شده و 210 حذف شده
  1. 1 1
      api/server/handlers/addons/tailscale_services.go
  2. 68 0
      api/server/handlers/neon_integration/list.go
  3. 28 0
      api/server/router/project.go
  4. 14 0
      dashboard/src/lib/databases/types.ts
  5. 2 2
      dashboard/src/lib/hooks/useAddon.ts
  6. 52 0
      dashboard/src/lib/hooks/useAuthWindow.ts
  7. 41 0
      dashboard/src/lib/hooks/useNeon.ts
  8. 6 0
      dashboard/src/lib/neon/types.ts
  9. 13 31
      dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx
  10. 1 1
      dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx
  11. 32 36
      dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx
  12. 37 2
      dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx
  13. 8 3
      dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx
  14. 105 17
      dashboard/src/main/home/database-dashboard/constants.ts
  15. 74 77
      dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx
  16. 84 6
      dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx
  17. 60 0
      dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx
  18. 3 3
      dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx
  19. 126 0
      dashboard/src/main/home/database-dashboard/tabs/PublicDatastoreConnectTab.tsx
  20. 29 27
      dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx
  21. 1 4
      go.mod
  22. 2 0
      go.sum
  23. 63 0
      internal/repository/gorm/neon.go
  24. 2 0
      internal/repository/neon.go
  25. 4 0
      internal/repository/test/neon.go

+ 1 - 1
api/server/handlers/addons/tailscale_services.go

@@ -79,7 +79,7 @@ func (c *TailscaleServicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	var services []TailscaleService
+	services := make([]TailscaleService, 0)
 	for _, svc := range svcList.Items {
 		var port int
 		if len(svc.Spec.Ports) > 0 {

+ 68 - 0
api/server/handlers/neon_integration/list.go

@@ -0,0 +1,68 @@
+package neon_integration
+
+import (
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListNeonIntegrationsHandler is a struct for listing all noen integrations for a given project
+type ListNeonIntegrationsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListNeonIntegrationsHandler constructs a ListNeonIntegrationsHandler
+func NewListNeonIntegrationsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListNeonIntegrationsHandler {
+	return &ListNeonIntegrationsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+type NeonIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+}
+
+// ListNeonIntegrationsResponse describes the list neon integrations response body
+type ListNeonIntegrationsResponse struct {
+	// Integrations is a list of neon integrations
+	Integrations []NeonIntegration `json:"integrations"`
+}
+
+// ServeHTTP returns a list of neon integrations associated with the specified project
+func (h *ListNeonIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-neon-integrations")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	resp := ListNeonIntegrationsResponse{}
+	integrationList := make([]NeonIntegration, 0)
+
+	integrations, err := h.Repo().NeonIntegration().Integrations(ctx, project.ID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting datastores")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, int := range integrations {
+		integrationList = append(integrationList, NeonIntegration{
+			CreatedAt: int.CreatedAt,
+		})
+	}
+
+	resp.Integrations = integrationList
+
+	h.WriteResult(w, r, resp)
+}

+ 28 - 0
api/server/router/project.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
+	"github.com/porter-dev/porter/api/server/handlers/neon_integration"
 
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 
@@ -2021,5 +2022,32 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/neon-integrations -> apiContract.NewListNeonIntegrationsHandler
+	listNeonIntegrationsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/neon-integrations",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listNeonIntegrationsHandler := neon_integration.NewListNeonIntegrationsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+	routes = append(routes, &router.Route{
+		Endpoint: listNeonIntegrationsEndpoint,
+		Handler:  listNeonIntegrationsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 14 - 0
dashboard/src/lib/databases/types.ts

@@ -164,6 +164,19 @@ export const DATASTORE_STATE_DELETED: DatastoreState = {
   displayName: "Wrapping up",
 };
 
+export type DatastoreTab = {
+  name: string;
+  displayName: string;
+  component: React.FC;
+  isOnlyForPorterOperators?: boolean;
+};
+
+export const DEFAULT_DATASTORE_TAB = {
+  name: "configuration",
+  displayName: "Configuration",
+  component: () => null,
+};
+
 export type DatastoreTemplate = {
   highLevelType: DatastoreEngine; // this was created so that rds aurora postgres and rds postgres can be grouped together
   type: DatastoreType;
@@ -177,6 +190,7 @@ export type DatastoreTemplate = {
   supportedEngineVersions: EngineVersion[];
   creationStateProgression: DatastoreState[];
   deletionStateProgression: DatastoreState[];
+  tabs: DatastoreTab[]; // this what is rendered on the dashboard after the datastore is deployed
 };
 
 const instanceTierValidator = z.enum([

+ 2 - 2
dashboard/src/lib/hooks/useAddon.ts

@@ -139,7 +139,7 @@ export const useAddonList = ({
               "monitoring",
               "porter-agent-system",
               "external-secrets",
-              "infisical"
+              "infisical",
             ].includes(a.namespace ?? "");
           });
       },
@@ -552,7 +552,7 @@ export const useAddonLogs = ({
   projectId?: number;
   deploymentTarget: DeploymentTarget;
   addon?: ClientAddon;
-}): { logs: Log[]; refresh: () => void; isInitializing: boolean } => {
+}): { logs: Log[]; refresh: () => Promise<void>; isInitializing: boolean } => {
   const [logs, setLogs] = useState<Log[]>([]);
   const logsBufferRef = useRef<Log[]>([]);
   const { newWebsocket, openWebsocket, closeAllWebsockets } = useWebsockets();

+ 52 - 0
dashboard/src/lib/hooks/useAuthWindow.ts

@@ -0,0 +1,52 @@
+import { useEffect, useState } from "react";
+
+/**
+ *  Hook to open an authentication window at a given url.
+ *  Once the auth flow redirects back to Porter, the window is closed.
+ */
+export const useAuthWindow = ({
+  authUrl,
+}: {
+  authUrl: string;
+}): {
+  openAuthWindow: () => void;
+} => {
+  const [authWindow, setAuthWindow] = useState<Window | null>(null);
+
+  const openAuthWindow = (): void => {
+    const windowObjectReference = window.open(
+      authUrl,
+      "porterAuthWindow",
+      "width=600,height=700,left=200,top=200"
+    );
+    setAuthWindow(windowObjectReference);
+  };
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      if (authWindow) {
+        try {
+          if (
+            authWindow.location.hostname.includes("dashboard.getporter.dev") ||
+            authWindow.location.hostname.includes("localhost")
+          ) {
+            authWindow.close();
+            setAuthWindow(null);
+            clearInterval(interval);
+          }
+        } catch (e) {
+          console.log("Error accessing the authentication window.", e);
+        }
+      }
+    }, 1000);
+
+    return () => {
+      clearInterval(interval);
+      if (authWindow) {
+        authWindow.close();
+      }
+    };
+  }, [authWindow]);
+
+  return { openAuthWindow };
+};

+ 41 - 0
dashboard/src/lib/hooks/useNeon.ts

@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+import {
+  neonIntegrationValidator,
+  type ClientNeonIntegration,
+} from "lib/neon/types";
+
+import api from "shared/api";
+
+type TUseNeon = {
+  getNeonIntegrations: ({
+    projectId,
+  }: {
+    projectId: number;
+  }) => Promise<ClientNeonIntegration[]>;
+};
+export const useNeon = (): TUseNeon => {
+  const getNeonIntegrations = async ({
+    projectId,
+  }: {
+    projectId: number;
+  }): Promise<ClientNeonIntegration[]> => {
+    const response = await api.getNeonIntegrations(
+      "<token>",
+      {},
+      {
+        projectId,
+      }
+    );
+
+    const results = await z
+      .object({ integrations: z.array(neonIntegrationValidator) })
+      .parseAsync(response.data);
+
+    return results.integrations;
+  };
+
+  return {
+    getNeonIntegrations,
+  };
+};

+ 6 - 0
dashboard/src/lib/neon/types.ts

@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const neonIntegrationValidator = z.object({
+  created_at: z.string(),
+});
+export type ClientNeonIntegration = z.infer<typeof neonIntegrationValidator>;

+ 13 - 31
dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx

@@ -9,10 +9,6 @@ import Banner from "components/porter/Banner";
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { type ClientAddon } from "lib/addons";
-import {
-  DEFAULT_ADDON_TAB,
-  SUPPORTED_ADDON_TEMPLATES,
-} from "lib/addons/template";
 
 import { Context } from "shared/Context";
 
@@ -37,38 +33,24 @@ const AddonTabs: React.FC<Props> = ({ tabParam }) => {
     reset(addon);
   }, [addon]);
 
-  const addonTemplate = useMemo(() => {
-    return SUPPORTED_ADDON_TEMPLATES.find(
-      (template) => template.type === addon.config.type
-    );
-  }, [addon]);
-
   const tabs = useMemo(() => {
-    if (addonTemplate) {
-      return addonTemplate.tabs
-        .filter(
-          (t) =>
-            !t.isOnlyForPorterOperators ||
-            (t.isOnlyForPorterOperators && user.isPorterUser)
-        )
-        .map((tab) => ({
-          label: tab.displayName,
-          value: tab.name,
-        }));
-    }
-    return [
-      {
-        label: DEFAULT_ADDON_TAB.displayName,
-        value: DEFAULT_ADDON_TAB.name,
-      },
-    ];
-  }, [addonTemplate]);
+    return addon.template.tabs
+      .filter(
+        (t) =>
+          !t.isOnlyForPorterOperators ||
+          (t.isOnlyForPorterOperators && user.isPorterUser)
+      )
+      .map((tab) => ({
+        label: tab.displayName,
+        value: tab.name,
+      }));
+  }, [addon.template]);
 
   const currentTab = useMemo(() => {
     if (tabParam && tabs.some((tab) => tab.value === tabParam)) {
       return tabParam;
     }
-    return tabs[0].value;
+    return tabs.length ? tabs[0].value : "";
   }, [tabParam, tabs]);
 
   return (
@@ -96,7 +78,7 @@ const AddonTabs: React.FC<Props> = ({ tabParam }) => {
         }}
       />
       <Spacer y={1} />
-      {addonTemplate?.tabs
+      {addon.template.tabs
         .filter(
           (t) =>
             !t.isOnlyForPorterOperators ||

+ 1 - 1
dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx

@@ -45,7 +45,7 @@ const TailscaleOverview: React.FC = () => {
 
       const parsed = await z
         .object({
-          services: z.array(tailscaleServiceValidator),
+          services: z.array(tailscaleServiceValidator).optional().default([]),
         })
         .parseAsync(res.data);
 

+ 32 - 36
dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx

@@ -1,26 +1,14 @@
-import React, { useMemo } from "react";
+import React, { useContext, useMemo } from "react";
 import { useHistory } from "react-router";
 import { match } from "ts-pattern";
 
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 
+import { Context } from "shared/Context";
+
 import { useDatastoreContext } from "./DatabaseContextProvider";
 import DatastoreProvisioningIndicator from "./DatastoreProvisioningIndicator";
-import ConfigurationTab from "./tabs/ConfigurationTab";
-import ConnectTab from "./tabs/ConnectTab";
-import MetricsTab from "./tabs/MetricsTab";
-import SettingsTab from "./tabs/SettingsTab";
-
-const validTabs = [
-  "metrics",
-  "connect",
-  "configuration",
-  "settings",
-  "connected-apps",
-] as const;
-const DEFAULT_TAB = "connect";
-type ValidTab = (typeof validTabs)[number];
 
 type DbTabProps = {
   tabParam?: string;
@@ -32,23 +20,27 @@ export type ButtonStatus = "" | "loading" | JSX.Element | "success";
 const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
   const history = useHistory();
   const { datastore } = useDatastoreContext();
+  const { user } = useContext(Context);
+
+  const tabs = useMemo(() => {
+    return datastore.template.tabs
+      .filter(
+        (t) =>
+          !t.isOnlyForPorterOperators ||
+          (t.isOnlyForPorterOperators && user.isPorterUser)
+      )
+      .map((tab) => ({
+        label: tab.displayName,
+        value: tab.name,
+      }));
+  }, [datastore.template]);
 
   const currentTab = useMemo(() => {
-    if (tabParam && validTabs.includes(tabParam as ValidTab)) {
-      return tabParam as ValidTab;
+    if (tabParam && tabs.some((tab) => tab.value === tabParam)) {
+      return tabParam;
     }
-
-    return DEFAULT_TAB;
-  }, [tabParam]);
-
-  const tabs = useMemo(() => {
-    return [
-      { label: "Connectivity", value: "connect" },
-      // { label: "Connected Apps", value: "connected-apps" },
-      { label: "Configuration", value: "configuration" },
-      { label: "Settings", value: "settings" },
-    ];
-  }, []);
+    return tabs.length ? tabs[0].value : "";
+  }, [tabParam, tabs]);
 
   if (datastore.status !== "AVAILABLE") {
     return <DatastoreProvisioningIndicator />;
@@ -65,13 +57,17 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
         }}
       />
       <Spacer y={1} />
-      {match(currentTab)
-        .with("connect", () => <ConnectTab />)
-        .with("settings", () => <SettingsTab />)
-        .with("metrics", () => <MetricsTab />)
-        .with("configuration", () => <ConfigurationTab />)
-        // .with("connected-apps", () => <ConnectedAppsTab />)
-        .otherwise(() => null)}
+      {datastore.template.tabs
+        .filter(
+          (t) =>
+            !t.isOnlyForPorterOperators ||
+            (t.isOnlyForPorterOperators && user.isPorterUser)
+        )
+        .map((tab) =>
+          match(currentTab)
+            .with(tab.name, () => <tab.component key={tab.name} />)
+            .otherwise(() => null)
+        )}
     </div>
   );
 };

+ 37 - 2
dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx

@@ -1,15 +1,21 @@
-import React, { createContext, useMemo, useState } from "react";
+import React, { createContext, useContext, useMemo, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { FormProvider, useForm } from "react-hook-form";
 import { useHistory } from "react-router";
 import styled from "styled-components";
 
+import Loading from "components/Loading";
 import { Error as ErrorComponent } from "components/porter/Error";
 import { dbFormValidator, type DbFormData } from "lib/databases/types";
 import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
 import { useDatastoreList } from "lib/hooks/useDatabaseList";
 import { useDatastore } from "lib/hooks/useDatastore";
 import { useIntercom } from "lib/hooks/useIntercom";
+import { useNeon } from "lib/hooks/useNeon";
+
+import { Context } from "shared/Context";
+
+import NeonIntegrationModal from "./shared/NeonIntegrationModal";
 
 // todo(ianedwards): refactor button to use more predictable state
 export type UpdateDatastoreButtonProps = {
@@ -20,6 +26,7 @@ export type UpdateDatastoreButtonProps = {
 
 type DatastoreFormContextType = {
   updateDatastoreButtonProps: UpdateDatastoreButtonProps;
+  projectId: number;
 };
 
 const DatastoreFormContext = createContext<DatastoreFormContextType | null>(
@@ -42,7 +49,12 @@ type DatastoreFormContextProviderProps = {
 const DatastoreFormContextProvider: React.FC<
   DatastoreFormContextProviderProps
 > = ({ children }) => {
+  const { currentProject } = useContext(Context);
+
   const [updateDatastoreError, setUpdateDatastoreError] = useState<string>("");
+  const { getNeonIntegrations } = useNeon();
+  const [showNeonIntegrationModal, setShowNeonIntegrationModal] =
+    useState(false);
 
   const { showIntercomWithMessage } = useIntercom();
 
@@ -85,6 +97,9 @@ const DatastoreFormContextProvider: React.FC<
   }, [isSubmitting, updateDatastoreError, errors]);
 
   const onSubmit = handleSubmit(async (data) => {
+    if (!currentProject) {
+      return;
+    }
     setUpdateDatastoreError("");
     if (existingDatastores.some((db) => db.name === data.name)) {
       setUpdateDatastoreError(
@@ -93,10 +108,18 @@ const DatastoreFormContextProvider: React.FC<
       return;
     }
     try {
+      if (data.config.type === "neon") {
+        const integrations = await getNeonIntegrations({
+          projectId: currentProject.id,
+        });
+        if (integrations.length === 0) {
+          setShowNeonIntegrationModal(true);
+          return;
+        }
+      }
       await createDatastore(data);
       history.push(`/datastores/${data.name}`);
     } catch (err) {
-      console.error(err);
       const errorMessage = getErrorMessageFromNetworkCall(
         err,
         "Datastore creation"
@@ -108,10 +131,15 @@ const DatastoreFormContextProvider: React.FC<
     }
   });
 
+  if (!currentProject) {
+    return <Loading />;
+  }
+
   return (
     <DatastoreFormContext.Provider
       value={{
         updateDatastoreButtonProps,
+        projectId: currentProject.id,
       }}
     >
       <Wrapper>
@@ -119,6 +147,13 @@ const DatastoreFormContextProvider: React.FC<
           <form onSubmit={onSubmit}>{children}</form>
         </FormProvider>
       </Wrapper>
+      {showNeonIntegrationModal && (
+        <NeonIntegrationModal
+          onClose={() => {
+            setShowNeonIntegrationModal(false);
+          }}
+        />
+      )}
     </DatastoreFormContext.Provider>
   );
 };

+ 8 - 3
dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx

@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
 
 import StatusBar from "components/porter/StatusBar";
 
+import { DATASTORE_TEMPLATE_NEON } from "./constants";
 import { useDatastoreContext } from "./DatabaseContextProvider";
 
 const DatastoreProvisioningIndicator: React.FC = () => {
@@ -39,14 +40,18 @@ const DatastoreProvisioningIndicator: React.FC = () => {
       return { percentCompleted, title, titleDescriptor, isCreating };
     }, [datastore]);
 
+  const subtitle = useMemo(() => {
+    return `${isCreating ? "Setup" : "Deletion"} can take up to ${
+      datastore.template === DATASTORE_TEMPLATE_NEON ? 5 : 20
+    } minutes. You can close this window and come back later.`;
+  }, [datastore]);
+
   return (
     <StatusBar
       icon={datastore.template.icon}
       title={title}
       titleDescriptor={titleDescriptor}
-      subtitle={`${
-        isCreating ? "Setup" : "Deletion"
-      } can take up to 20 minutes. You can close this window and come back later.`}
+      subtitle={subtitle}
       percentCompleted={percentCompleted}
     />
   );

+ 105 - 17
dashboard/src/main/home/database-dashboard/constants.ts

@@ -26,6 +26,11 @@ import neon from "assets/neon.svg";
 import postgresql from "assets/postgresql.svg";
 import redis from "assets/redis.svg";
 
+import ConfigurationTab from "./tabs/ConfigurationTab";
+import ConnectTab from "./tabs/ConnectTab";
+import PublicDatastoreConnectTab from "./tabs/PublicDatastoreConnectTab";
+import SettingsTab from "./tabs/SettingsTab";
+
 export const DATASTORE_ENGINE_POSTGRES: DatastoreEngine = {
   name: "POSTGRES" as const,
   displayName: "PostgreSQL",
@@ -118,6 +123,23 @@ export const DATASTORE_TEMPLATE_AWS_RDS: DatastoreTemplate = Object.freeze({
     DATASTORE_STATE_DELETING_RECORD,
     DATASTORE_STATE_DELETED,
   ],
+  tabs: [
+    {
+      name: "connectivity",
+      displayName: "Connectivity",
+      component: ConnectTab,
+    },
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: ConfigurationTab,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: SettingsTab,
+    },
+  ],
 });
 export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({
   name: "Amazon Aurora",
@@ -155,6 +177,23 @@ export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({
     DATASTORE_STATE_DELETING_RECORD,
     DATASTORE_STATE_DELETED,
   ],
+  tabs: [
+    {
+      name: "connectivity",
+      displayName: "Connectivity",
+      component: ConnectTab,
+    },
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: ConfigurationTab,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: SettingsTab,
+    },
+  ],
 });
 export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate =
   Object.freeze({
@@ -217,6 +256,23 @@ export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate =
       DATASTORE_STATE_DELETING_RECORD,
       DATASTORE_STATE_DELETED,
     ],
+    tabs: [
+      {
+        name: "connectivity",
+        displayName: "Connectivity",
+        component: ConnectTab,
+      },
+      {
+        name: "configuration",
+        displayName: "Configuration",
+        component: ConfigurationTab,
+      },
+      {
+        name: "settings",
+        displayName: "Settings",
+        component: SettingsTab,
+      },
+    ],
   });
 export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate =
   Object.freeze({
@@ -254,6 +310,23 @@ export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate =
       DATASTORE_STATE_DELETING_RECORD,
       DATASTORE_STATE_DELETED,
     ],
+    tabs: [
+      {
+        name: "connectivity",
+        displayName: "Connectivity",
+        component: ConnectTab,
+      },
+      {
+        name: "configuration",
+        displayName: "Configuration",
+        component: ConfigurationTab,
+      },
+      {
+        name: "settings",
+        displayName: "Settings",
+        component: SettingsTab,
+      },
+    ],
   });
 export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate =
   Object.freeze({
@@ -291,6 +364,23 @@ export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate =
       DATASTORE_STATE_DELETING_RECORD,
       DATASTORE_STATE_DELETED,
     ],
+    tabs: [
+      {
+        name: "connectivity",
+        displayName: "Connectivity",
+        component: ConnectTab,
+      },
+      {
+        name: "configuration",
+        displayName: "Configuration",
+        component: ConfigurationTab,
+      },
+      {
+        name: "settings",
+        displayName: "Settings",
+        component: SettingsTab,
+      },
+    ],
   });
 
 export const DATASTORE_TEMPLATE_NEON: DatastoreTemplate = Object.freeze({
@@ -301,24 +391,10 @@ export const DATASTORE_TEMPLATE_NEON: DatastoreTemplate = Object.freeze({
   engine: DATASTORE_ENGINE_POSTGRES,
   supportedEngineVersions: [],
   icon: neon as string,
-  description: "A postgresql instance hosted by Neon.",
+  description:
+    "A fully managed serverless Postgres. Neon separates storage and compute to offer autoscaling, branching, and bottomless storage.",
   disabled: true,
-  instanceTiers: [
-    {
-      tier: "db.t4g.micro" as const,
-      label: "Micro",
-      cpuCores: 1,
-      ramGigabytes: 1,
-      storageGigabytes: 1,
-    },
-    {
-      tier: "db.t4g.small" as const,
-      label: "Small",
-      cpuCores: 2,
-      ramGigabytes: 2,
-      storageGigabytes: 2,
-    },
-  ],
+  instanceTiers: [],
   creationStateProgression: [
     DATASTORE_STATE_CREATING,
     DATASTORE_STATE_AVAILABLE,
@@ -328,6 +404,18 @@ export const DATASTORE_TEMPLATE_NEON: DatastoreTemplate = Object.freeze({
     DATASTORE_STATE_DELETING_RECORD,
     DATASTORE_STATE_DELETED,
   ],
+  tabs: [
+    {
+      name: "connectivity",
+      displayName: "Connectivity",
+      component: PublicDatastoreConnectTab,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: SettingsTab,
+    },
+  ],
 });
 
 export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [

+ 74 - 77
dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx

@@ -24,6 +24,7 @@ import {
 } from "lib/databases/types";
 import { useClusterList } from "lib/hooks/useCluster";
 
+import { valueExists } from "shared/util";
 import database from "assets/database.svg";
 
 import {
@@ -325,82 +326,78 @@ const DatastoreForm: React.FC = () => {
                 </>
               )}
             </>,
-            <>
-              <Text size={16}>Specify resources</Text>
-              {template && (
-                <>
-                  <Spacer y={0.5} />
-                  <Text color="helper">
-                    Specify your datastore CPU and RAM.
-                  </Text>
-                  {errors.config?.instanceClass?.message && (
-                    <AppearingErrorContainer>
-                      <Spacer y={0.5} />
-                      <ErrorComponent
-                        message={errors.config.instanceClass.message}
-                      />
-                    </AppearingErrorContainer>
-                  )}
-                  <Spacer y={0.5} />
-                  <Text>Select an instance tier:</Text>
-                  <Spacer height="20px" />
-                  <Resources
-                    options={template.instanceTiers}
-                    selected={watchTier}
-                    onSelect={(option: ResourceOption) => {
-                      setValue("config.instanceClass", option.tier);
-                      setValue(
-                        "config.allocatedStorageGigabytes",
-                        option.storageGigabytes
-                      );
-                      setCurrentStep(6);
-                    }}
-                    highlight={watchEngine === "REDIS" ? "ram" : "storage"}
-                  />
-                </>
-              )}
-            </>,
-            <>
-              <Text size={16}>Credentials</Text>
-              {watchInstanceClass !== "unspecified" && template && (
-                <>
-                  <Spacer y={0.5} />
-                  <Text color="helper">
-                    These credentials never leave your own cloud environment.
-                    Your app will use them to connect to this datastore.
-                  </Text>
-                  <Spacer height="20px" />
-                  <ConnectionInfo
-                    connectionInfo={
-                      watchEngine === "REDIS"
-                        ? {
-                            host: "(determined after creation)",
-                            port: 6379,
-                            password: watchDbPassword,
-                            username: "",
-                            database_name: "",
-                          }
-                        : template === DATASTORE_TEMPLATE_NEON
-                        ? {
-                            host: "(determined after creation)",
-                            port: 5432,
-                            password: "(determined after creation)",
-                            username: "(determined after creation)",
-                            database_name: "(determined after creation)",
-                          }
-                        : {
-                            host: "(determined after creation)",
-                            port: 5432,
-                            password: watchDbPassword,
-                            username: watchDbUsername,
-                            database_name: watchDbName,
-                          }
-                    }
-                    engine={template.engine}
-                  />
-                </>
-              )}
-            </>,
+            template !== DATASTORE_TEMPLATE_NEON ? (
+              <>
+                <Text size={16}>Specify resources</Text>
+                {template && (
+                  <>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Specify your datastore CPU and RAM.
+                    </Text>
+                    {errors.config?.instanceClass?.message && (
+                      <AppearingErrorContainer>
+                        <Spacer y={0.5} />
+                        <ErrorComponent
+                          message={errors.config.instanceClass.message}
+                        />
+                      </AppearingErrorContainer>
+                    )}
+                    <Spacer y={0.5} />
+                    <Text>Select an instance tier:</Text>
+                    <Spacer height="20px" />
+                    <Resources
+                      options={template.instanceTiers}
+                      selected={watchTier}
+                      onSelect={(option: ResourceOption) => {
+                        setValue("config.instanceClass", option.tier);
+                        setValue(
+                          "config.allocatedStorageGigabytes",
+                          option.storageGigabytes
+                        );
+                        setCurrentStep(6);
+                      }}
+                      highlight={watchEngine === "REDIS" ? "ram" : "storage"}
+                    />
+                  </>
+                )}
+              </>
+            ) : null,
+            template !== DATASTORE_TEMPLATE_NEON ? (
+              <>
+                <Text size={16}>Credentials</Text>
+                {watchInstanceClass !== "unspecified" && template && (
+                  <>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      These credentials never leave your own cloud environment.
+                      Your app will use them to connect to this datastore.
+                    </Text>
+                    <Spacer height="20px" />
+                    <ConnectionInfo
+                      connectionInfo={
+                        watchEngine === "REDIS"
+                          ? {
+                              host: "(determined after creation)",
+                              port: 6379,
+                              password: watchDbPassword,
+                              username: "",
+                              database_name: "",
+                            }
+                          : {
+                              host: "(determined after creation)",
+                              port: 5432,
+                              password: watchDbPassword,
+                              username: watchDbUsername,
+                              database_name: watchDbName,
+                            }
+                      }
+                      template={template}
+                    />
+                  </>
+                )}
+              </>
+            ) : null,
             <>
               <Text size={16}>Create datastore instance</Text>
               <Spacer y={0.5} />
@@ -413,7 +410,7 @@ const DatastoreForm: React.FC = () => {
                 Create
               </Button>
             </>,
-          ]}
+          ].filter(valueExists)}
           currentStep={currentStep}
         />
       </StyledConfigureTemplate>

+ 84 - 6
dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx

@@ -1,5 +1,6 @@
-import React from "react";
+import React, { useMemo } from "react";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
 import ClickToCopy from "components/porter/ClickToCopy";
 import Container from "components/porter/Container";
@@ -7,19 +8,62 @@ import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
+  DATASTORE_TYPE_ELASTICACHE,
+  DATASTORE_TYPE_MANAGED_POSTGRES,
+  DATASTORE_TYPE_MANAGED_REDIS,
+  DATASTORE_TYPE_NEON,
+  DATASTORE_TYPE_RDS,
   type DatastoreConnectionInfo,
-  type DatastoreEngine,
+  type DatastoreTemplate,
 } from "lib/databases/types";
 
-import { DATASTORE_ENGINE_REDIS } from "../constants";
+import {
+  DATASTORE_ENGINE_POSTGRES,
+  DATASTORE_ENGINE_REDIS,
+} from "../constants";
 
 type Props = {
   connectionInfo: DatastoreConnectionInfo;
-  engine: DatastoreEngine;
+  template: DatastoreTemplate;
 };
-const ConnectionInfo: React.FC<Props> = ({ connectionInfo, engine }) => {
+const ConnectionInfo: React.FC<Props> = ({ connectionInfo, template }) => {
   const [isPasswordHidden, setIsPasswordHidden] = React.useState<boolean>(true);
 
+  const connectionString = useMemo(() => {
+    return match(template)
+      .returnType<string>()
+      .with({ highLevelType: DATASTORE_ENGINE_REDIS }, () =>
+        match(template)
+          .with(
+            { type: DATASTORE_TYPE_ELASTICACHE },
+            () =>
+              `rediss://:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0?ssl_cert_reqs=CERT_REQUIRED`
+          )
+          .with(
+            { type: DATASTORE_TYPE_MANAGED_REDIS },
+            () =>
+              `redis://:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0`
+          )
+          .otherwise(() => "")
+      )
+      .with({ highLevelType: DATASTORE_ENGINE_POSTGRES }, () =>
+        match(template)
+          .with(
+            { type: DATASTORE_TYPE_RDS },
+            { type: DATASTORE_TYPE_NEON },
+            () =>
+              `postgres://${connectionInfo.username}:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/${connectionInfo.database_name}?sslmode=require`
+          )
+          .with(
+            { type: DATASTORE_TYPE_MANAGED_POSTGRES },
+            () =>
+              `postgres://${connectionInfo.username}:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/${connectionInfo.database_name}`
+          )
+          .otherwise(() => "")
+      )
+      .otherwise(() => "");
+  }, [template]);
+
   return (
     <Fieldset>
       <table style={{ borderSpacing: "5px" }}>
@@ -42,7 +86,7 @@ const ConnectionInfo: React.FC<Props> = ({ connectionInfo, engine }) => {
               </ClickToCopy>
             </td>
           </tr>
-          {engine === DATASTORE_ENGINE_REDIS ? (
+          {template.highLevelType === DATASTORE_ENGINE_REDIS ? (
             <tr>
               <td>
                 <Text>Auth token</Text>
@@ -135,6 +179,40 @@ const ConnectionInfo: React.FC<Props> = ({ connectionInfo, engine }) => {
               </tr>
             </>
           )}
+          {connectionString !== "" && (
+            <tr>
+              <td>
+                <Text>Connection string</Text>
+              </td>
+              <td>
+                {isPasswordHidden ? (
+                  <Container row>
+                    <Blur>{connectionString}</Blur>
+                    <Spacer inline width="10px" />
+                    <RevealButton
+                      onClick={() => {
+                        setIsPasswordHidden(false);
+                      }}
+                    >
+                      Reveal
+                    </RevealButton>
+                  </Container>
+                ) : (
+                  <Container row>
+                    <ClickToCopy color="helper">{connectionString}</ClickToCopy>
+                    <Spacer inline width="10px" />
+                    <RevealButton
+                      onClick={() => {
+                        setIsPasswordHidden(true);
+                      }}
+                    >
+                      Hide
+                    </RevealButton>
+                  </Container>
+                )}
+              </td>
+            </tr>
+          )}
         </tbody>
       </table>
     </Fieldset>

+ 60 - 0
dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx

@@ -0,0 +1,60 @@
+import React, { useEffect } from "react";
+import { useQuery } from "@tanstack/react-query";
+
+import Link from "components/porter/Link";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useAuthWindow } from "lib/hooks/useAuthWindow";
+import { useNeon } from "lib/hooks/useNeon";
+
+import { useDatastoreFormContext } from "../DatastoreFormContextProvider";
+
+type Props = {
+  onClose: () => void;
+};
+
+const NeonIntegrationModal: React.FC<Props> = ({ onClose }) => {
+  const { projectId } = useDatastoreFormContext();
+  const { getNeonIntegrations } = useNeon();
+  const { openAuthWindow } = useAuthWindow({
+    authUrl: `/api/projects/${projectId}/oauth/neon`,
+  });
+
+  const neonIntegrationsResp = useQuery(
+    ["getNeonIntegrations", projectId],
+    async () => {
+      const integrations = await getNeonIntegrations({
+        projectId,
+      });
+      return integrations;
+    },
+    {
+      enabled: !!projectId,
+      refetchInterval: 1000,
+    }
+  );
+  useEffect(() => {
+    if (
+      neonIntegrationsResp.isSuccess &&
+      neonIntegrationsResp.data.length > 0
+    ) {
+      onClose();
+    }
+  }, [neonIntegrationsResp]);
+
+  return (
+    <Modal closeModal={onClose} width={"800px"}>
+      <Text size={16}>Integrate Neon</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        To continue, you must authenticate with Neon.{" "}
+        <Link target="_blank" onClick={openAuthWindow} hasunderline>
+          Authorize Porter to create Neon datastores on your behalf
+        </Link>
+      </Text>
+    </Modal>
+  );
+};
+
+export default NeonIntegrationModal;

+ 3 - 3
dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx

@@ -59,7 +59,7 @@ const ConnectTab: React.FC = () => {
         <Spacer y={0.5} />
         <ConnectionInfo
           connectionInfo={datastore.credential}
-          engine={datastore.template.engine}
+          template={datastore.template}
         />
         <Spacer y={0.5} />
         <Text color="warner">
@@ -124,14 +124,14 @@ const ConnectTab: React.FC = () => {
 
 export default ConnectTab;
 
-const ConnectTabContainer = styled.div`
+export const ConnectTabContainer = styled.div`
   width: 100%;
   height: 100%;
   display: flex;
   flex-direction: row;
 `;
 
-const IdContainer = styled.div`
+export const IdContainer = styled.div`
   width: fit-content;
   background: #000000;
   border-radius: 5px;

+ 126 - 0
dashboard/src/main/home/database-dashboard/tabs/PublicDatastoreConnectTab.tsx

@@ -0,0 +1,126 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+
+import Banner from "components/porter/Banner";
+import Container from "components/porter/Container";
+import ShowIntercomButton from "components/porter/ShowIntercomButton";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import { Context } from "shared/Context";
+
+import { useDatastoreContext } from "../DatabaseContextProvider";
+import ConnectAppsModal from "../shared/ConnectAppsModal";
+import ConnectionInfo from "../shared/ConnectionInfo";
+
+// use this for external datastores that are publicly exposed like neon, upstash, etc.
+const PublicDatastoreConnectTab: React.FC = () => {
+  const { datastore } = useDatastoreContext();
+  const { currentProject } = useContext(Context);
+  const [showConnectAppsModal, setShowConnectAppsModal] = useState(false);
+
+  if (datastore.credential.host === "") {
+    return (
+      <Banner
+        type="error"
+        suffix={
+          <>
+            <ShowIntercomButton
+              message={"I need help retrieving credentials for my datastore."}
+            >
+              Talk to support
+            </ShowIntercomButton>
+          </>
+        }
+      >
+        Error reaching your datastore for credentials. Please contact support.
+        <Spacer inline width="5px" />
+      </Banner>
+    );
+  }
+  return (
+    <ConnectTabContainer>
+      <div
+        style={{
+          width: "100%",
+          height: "100%",
+          paddingRight: "10px",
+        }}
+      >
+        <Container row>
+          <Text size={16}>Connection info</Text>
+        </Container>
+        <Spacer y={0.5} />
+        <ConnectionInfo
+          connectionInfo={datastore.credential}
+          template={datastore.template}
+        />
+        <Spacer y={0.5} />
+        <Text color="helper">
+          The datastore client of your application should use these credentials
+          to create a connection.{" "}
+        </Text>
+        {!currentProject?.sandbox_enabled && (
+          <>
+            <Spacer y={1} />
+            <ConnectAppButton
+              onClick={() => {
+                setShowConnectAppsModal(true);
+              }}
+            >
+              <I className="material-icons add-icon">add</I>
+              Inject these credentials into an app
+            </ConnectAppButton>
+            {showConnectAppsModal && (
+              <ConnectAppsModal
+                closeModal={() => {
+                  setShowConnectAppsModal(false);
+                }}
+              />
+            )}
+          </>
+        )}
+      </div>
+    </ConnectTabContainer>
+  );
+};
+
+export default PublicDatastoreConnectTab;
+
+const ConnectTabContainer = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
+`;
+
+const ConnectAppButton = styled.div`
+  color: #aaaabb;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  .add-icon {
+    width: 30px;
+    font-size: 20px;
+  }
+`;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 7px;
+  justify-content: center;
+`;

+ 29 - 27
dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx

@@ -46,7 +46,6 @@ const SettingsTab: React.FC = () => {
       </StyledTemplateComponent>
       {showDeleteDatastoreModal && (
         <DeleteDatastoreModal
-          datastoreName={datastore.name}
           onClose={() => {
             setShowDeleteDatastoreModal(false);
           }}
@@ -62,16 +61,16 @@ const SettingsTab: React.FC = () => {
 export default SettingsTab;
 
 type DeleteDatastoreModalProps = {
-  datastoreName: string;
   onSubmit: () => Promise<void>;
   onClose: () => void;
 };
 
 const DeleteDatastoreModal: React.FC<DeleteDatastoreModalProps> = ({
-  datastoreName,
   onSubmit,
   onClose,
 }) => {
+  const { datastore } = useDatastoreContext();
+
   const [inputtedDatastoreName, setInputtedDatastoreName] =
     useState<string>("");
   const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
@@ -114,33 +113,36 @@ const DeleteDatastoreModal: React.FC<DeleteDatastoreModalProps> = ({
 
   return (
     <Modal closeModal={onClose}>
-      <Text size={16}>Delete {datastoreName}?</Text>
-      <Spacer y={1} />
-
-      <Text size={14} color="red">
-        Attention:
-      </Text>
-      <Spacer y={0.1} />
-      <Text>
-        Destruction of resources sometimes results in dangling resources. To
-        ensure that everything has been properly destroyed, please visit your
-        cloud provider&apos;s console.
-      </Text>
-      <Spacer y={0.5} />
-      <Link
-        target="_blank"
-        hasunderline
-        to="https://docs.porter.run/other/deleting-dangling-resources"
-      >
-        Deletion instructions
-      </Link>
+      <Text size={16}>Delete {datastore.name}?</Text>
       <Spacer y={1} />
+      {datastore.cloud_provider_credential_identifier !== "" && (
+        <>
+          <Text size={14} color="red">
+            Attention:
+          </Text>
+          <Spacer y={0.1} />
+          <Text>
+            Destruction of resources sometimes results in dangling resources. To
+            ensure that everything has been properly destroyed, please visit
+            your cloud provider&apos;s console.
+          </Text>
+          <Spacer y={0.5} />
+          <Link
+            target="_blank"
+            hasunderline
+            to="https://docs.porter.run/other/deleting-dangling-resources"
+          >
+            Deletion instructions
+          </Link>
+          <Spacer y={1} />
+        </>
+      )}
       <Text color="helper">
         To confirm, enter the datastore name below. This action is irreversible.
       </Text>
       <Spacer y={0.5} />
       <Input
-        placeholder={datastoreName}
+        placeholder={datastore.name}
         value={inputtedDatastoreName}
         setValue={setInputtedDatastoreName}
         width="100%"
@@ -149,13 +151,13 @@ const DeleteDatastoreModal: React.FC<DeleteDatastoreModalProps> = ({
       <Spacer y={1} />
       <Button
         color="#b91133"
-        onClick={async () => {
-          await confirmDeletion();
+        onClick={() => {
+          void confirmDeletion();
         }}
         status={deleteButtonProps.status}
         disabled={
           deleteButtonProps.isDisabled ||
-          inputtedDatastoreName !== datastoreName
+          inputtedDatastoreName !== datastore.name
         }
         loadingText={"Deleting..."}
       >

+ 1 - 4
go.mod

@@ -2,9 +2,6 @@ module github.com/porter-dev/porter
 
 go 1.20
 
-// replace api-contracts with local path
-replace github.com/porter-dev/api-contracts => ../api-contracts
-
 require (
 	cloud.google.com/go v0.110.2 // indirect
 	github.com/AlecAivazis/survey/v2 v2.2.9
@@ -90,7 +87,7 @@ require (
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
 	github.com/ory/client-go v1.9.0
-	github.com/porter-dev/api-contracts v0.2.158
+	github.com/porter-dev/api-contracts v0.2.159
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 0
go.sum

@@ -1565,6 +1565,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/porter-dev/api-contracts v0.2.158 h1:928I9vELiqntau4Yp8cVuX7FcLgo95Lv2uBVYj84is8=
 github.com/porter-dev/api-contracts v0.2.158/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.159 h1:Ze4K0rm8p6sRMxaFW4Nb3dJuzz4NEMQ+UMXMtOKKRQ4=
+github.com/porter-dev/api-contracts v0.2.159/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 63 - 0
internal/repository/gorm/neon.go

@@ -42,6 +42,31 @@ func (repo *NeonIntegrationRepository) Insert(
 	return created, nil
 }
 
+// Integrations returns all neon integrations for a given project
+func (repo *NeonIntegrationRepository) Integrations(
+	ctx context.Context, projectID uint,
+) ([]ints.NeonIntegration, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-list-neon-integrations")
+	defer span.End()
+
+	var integrations []ints.NeonIntegration
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&integrations).Error; err != nil {
+		return integrations, telemetry.Error(ctx, span, err, "failed to list neon integrations")
+	}
+
+	for i, integration := range integrations {
+		decrypted, err := repo.DecryptNeonIntegration(integration, repo.key)
+		if err != nil {
+			return integrations, telemetry.Error(ctx, span, err, "failed to decrypt")
+		}
+
+		integrations[i] = decrypted
+	}
+
+	return integrations, nil
+}
+
 // EncryptNeonIntegration will encrypt the neon integration data before
 // writing to the DB
 func (repo *NeonIntegrationRepository) EncryptNeonIntegration(
@@ -79,3 +104,41 @@ func (repo *NeonIntegrationRepository) EncryptNeonIntegration(
 
 	return encrypted, nil
 }
+
+// DecryptNeonIntegrationData will decrypt the neon integration data before
+// returning it from the DB
+func (repo *NeonIntegrationRepository) DecryptNeonIntegration(
+	neonInt ints.NeonIntegration,
+	key *[32]byte,
+) (ints.NeonIntegration, error) {
+	decrypted := neonInt
+
+	if len(decrypted.ClientID) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.ClientID, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.ClientID = plaintext
+	}
+
+	if len(decrypted.AccessToken) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.AccessToken, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.AccessToken = plaintext
+	}
+
+	if len(decrypted.RefreshToken) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.RefreshToken, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.RefreshToken = plaintext
+	}
+
+	return decrypted, nil
+}

+ 2 - 0
internal/repository/neon.go

@@ -10,4 +10,6 @@ import (
 type NeonIntegrationRepository interface {
 	// Insert creates a new neon integration
 	Insert(ctx context.Context, neonInt ints.NeonIntegration) (ints.NeonIntegration, error)
+	// Integrations returns all neon integrations for a given project
+	Integrations(ctx context.Context, projectID uint) ([]ints.NeonIntegration, error)
 }

+ 4 - 0
internal/repository/test/neon.go

@@ -16,3 +16,7 @@ func NewNeonIntegrationRepository(canQuery bool) repository.NeonIntegrationRepos
 func (s *NeonIntegrationRepository) Insert(ctx context.Context, neonInt ints.NeonIntegration) (ints.NeonIntegration, error) {
 	panic("not implemented") // TODO: Implement
 }
+
+func (s *NeonIntegrationRepository) Integrations(ctx context.Context, projectID uint) ([]ints.NeonIntegration, error) {
+	panic("not implemented") // TODO: Implement
+}