Feroze Mohideen 2 лет назад
Родитель
Сommit
8e442ec055
34 измененных файлов с 1062 добавлено и 125 удалено
  1. 1 1
      api/server/handlers/addons/tailscale_services.go
  2. 3 0
      api/server/handlers/datastore/update.go
  3. 69 0
      api/server/handlers/neon_integration/list.go
  4. 1 0
      api/server/handlers/oauth_callback/neon.go
  5. 28 0
      api/server/router/project.go
  6. 20 0
      dashboard/src/assets/neon.svg
  7. 1 0
      dashboard/src/components/porter/BlockSelect.tsx
  8. 24 1
      dashboard/src/lib/databases/types.ts
  9. 2 2
      dashboard/src/lib/hooks/useAddon.ts
  10. 52 0
      dashboard/src/lib/hooks/useAuthWindow.ts
  11. 14 1
      dashboard/src/lib/hooks/useDatastore.ts
  12. 41 0
      dashboard/src/lib/hooks/useNeon.ts
  13. 6 0
      dashboard/src/lib/neon/types.ts
  14. 13 31
      dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx
  15. 1 1
      dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx
  16. 1 2
      dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx
  17. 32 36
      dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx
  18. 37 1
      dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx
  19. 8 3
      dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx
  20. 128 5
      dashboard/src/main/home/database-dashboard/constants.ts
  21. 16 2
      dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx
  22. 1 1
      dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx
  23. 236 0
      dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx
  24. 84 6
      dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx
  25. 60 0
      dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx
  26. 3 3
      dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx
  27. 70 0
      dashboard/src/main/home/database-dashboard/tabs/PublicDatastoreConnectTab.tsx
  28. 29 27
      dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx
  29. 9 1
      dashboard/src/shared/api.tsx
  30. 1 1
      go.mod
  31. 2 0
      go.sum
  32. 63 0
      internal/repository/gorm/neon.go
  33. 2 0
      internal/repository/neon.go
  34. 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 {

+ 3 - 0
api/server/handlers/datastore/update.go

@@ -187,6 +187,9 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 				MasterUserPasswordLiteral: pointer.String(datastoreValues.Config.MasterUserPassword),
 			},
 		}
+	case "NEON":
+		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_NEON
+		datastoreProto.KindValues = &porterv1.ManagedDatastore_NeonKind{}
 	default:
 		err = telemetry.Error(ctx, span, nil, "invalid datastore type")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))

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

@@ -0,0 +1,69 @@
+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),
+	}
+}
+
+// NeonIntegration describes a neon integration
+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)
+}

+ 1 - 0
api/server/handlers/oauth_callback/neon.go

@@ -93,6 +93,7 @@ func (p *OAuthCallbackNeonHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	oauthInt := integrations.NeonIntegration{
 		SharedOAuthModel: integrations.SharedOAuthModel{
+			ClientID:     []byte(p.Config().NeonConf.ClientID),
 			AccessToken:  []byte(token.AccessToken),
 			RefreshToken: []byte(token.RefreshToken),
 			Expiry:       token.Expiry,

+ 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
 }

+ 20 - 0
dashboard/src/assets/neon.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
+    <title>Neon</title>
+    <defs>
+        <linearGradient x1="100%" y1="100%" x2="12.0694444%" y2="0%" id="linearGradient-1">
+            <stop stop-color="#62F755" offset="0%"></stop>
+            <stop stop-color="#8FF986" stop-opacity="0" offset="100%"></stop>
+        </linearGradient>
+        <linearGradient x1="100%" y1="100%" x2="40.6027778%" y2="76.8972222%" id="linearGradient-2">
+            <stop stop-color="#000000" stop-opacity="0.9" offset="0%"></stop>
+            <stop stop-color="#1A1A1A" stop-opacity="0" offset="100%"></stop>
+        </linearGradient>
+    </defs>
+    <g>
+        <path d="M0,44.1386667 C0,19.7615542 19.7615542,0 44.1386667,0 L211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 L44.1386667,256 C19.7615542,256 0,236.238446 0,211.861333 L0,44.1386667 Z M44.1386667,35.3137778 C39.2604444,35.3137778 35.3137778,39.2604444 35.3137778,44.1315556 L35.3137778,211.861333 C35.3137778,216.739556 39.2604444,220.693333 44.1315556,220.693333 L121.820444,220.693333 C124.259556,220.693333 124.906667,218.716444 124.906667,216.277333 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,44.1386667 C220.693333,39.2604444 221.148444,35.3137778 216.277333,35.3137778 L44.1386667,35.3137778 Z" fill="#00E0D9"></path>
+        <path d="M0,44.1386667 C0,19.7615542 19.7615542,0 44.1386667,0 L211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 L44.1386667,256 C19.7615542,256 0,236.238446 0,211.861333 L0,44.1386667 Z M44.1386667,35.3137778 C39.2604444,35.3137778 35.3137778,39.2604444 35.3137778,44.1315556 L35.3137778,211.861333 C35.3137778,216.739556 39.2604444,220.693333 44.1315556,220.693333 L121.820444,220.693333 C124.259556,220.693333 124.906667,218.716444 124.906667,216.277333 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,44.1386667 C220.693333,39.2604444 221.148444,35.3137778 216.277333,35.3137778 L44.1386667,35.3137778 Z" fill="url(#linearGradient-1)"></path>
+        <path d="M0,44.1386667 C0,19.7615542 19.7615542,0 44.1386667,0 L211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 L44.1386667,256 C19.7615542,256 0,236.238446 0,211.861333 L0,44.1386667 Z M44.1386667,35.3137778 C39.2604444,35.3137778 35.3137778,39.2604444 35.3137778,44.1315556 L35.3137778,211.861333 C35.3137778,216.739556 39.2604444,220.693333 44.1315556,220.693333 L121.820444,220.693333 C124.259556,220.693333 124.906667,218.716444 124.906667,216.277333 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,44.1386667 C220.693333,39.2604444 221.148444,35.3137778 216.277333,35.3137778 L44.1386667,35.3137778 Z" fill-opacity="0.4" fill="url(#linearGradient-2)"></path>
+        <path d="M211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 C121.667088,256 122.788506,255.535493 123.615333,254.708666 C124.44216,253.881839 124.906667,252.760421 124.906667,251.591111 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,8.82488889 C220.693333,3.95377778 216.739556,0 211.861333,0 Z" fill="#63F655"></path>
+    </g>
+</svg>

+ 1 - 0
dashboard/src/components/porter/BlockSelect.tsx

@@ -100,6 +100,7 @@ const Block = styled.div<{ selected?: boolean; disabled?: boolean }>`
   display: flex;
   flex-direction: column;
   height: 100%;
+  width: 100%;
   align-items: left;
   user-select: none;
   font-size: 13px;

+ 24 - 1
dashboard/src/lib/databases/types.ts

@@ -36,6 +36,7 @@ const datastoreTypeValidator = z.enum([
   "ELASTICACHE",
   "MANAGED_REDIS",
   "MANAGED_POSTGRES",
+  "NEON",
 ]);
 const datastoreEngineValidator = z.enum([
   "UNKNOWN",
@@ -109,6 +110,10 @@ export const DATASTORE_TYPE_MANAGED_REDIS: DatastoreType = {
   name: "MANAGED_REDIS" as const,
   displayName: "Managed Redis",
 };
+export const DATASTORE_TYPE_NEON: DatastoreType = {
+  name: "NEON" as const,
+  displayName: "Neon",
+};
 
 export type DatastoreState = {
   state: z.infer<typeof datastoreValidator>["status"];
@@ -159,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;
@@ -170,9 +188,9 @@ export type DatastoreTemplate = {
   disabled: boolean;
   instanceTiers: ResourceOption[];
   supportedEngineVersions: EngineVersion[];
-  formTitle: string;
   creationStateProgression: DatastoreState[];
   deletionStateProgression: DatastoreState[];
+  tabs: DatastoreTab[]; // this what is rendered on the dashboard after the datastore is deployed
 };
 
 const instanceTierValidator = z.enum([
@@ -312,6 +330,10 @@ const managedRedisConfigValidator = z.object({
     .default(1),
 });
 
+const neonValidator = z.object({
+  type: z.literal("neon"),
+});
+
 export const dbFormValidator = z.object({
   name: z
     .string()
@@ -332,6 +354,7 @@ export const dbFormValidator = z.object({
     elasticacheRedisConfigValidator,
     managedRedisConfigValidator,
     managedPostgresConfigValidator,
+    neonValidator,
   ]),
   clusterId: z.number(),
 });

+ 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 };
+};

+ 14 - 1
dashboard/src/lib/hooks/useDatastore.ts

@@ -21,7 +21,7 @@ type DatastoreHook = {
 };
 type CreateDatastoreInput = {
   name: string;
-  type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS";
+  type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS" | "NEON";
   engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
   values: object;
 };
@@ -134,6 +134,19 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
         };
       }
     )
+    .with(
+      { config: { type: "neon" } },
+      (values): CreateDatastoreInput => ({
+        name: values.name,
+        values: {
+          config: {
+            name: values.name,
+          },
+        },
+        type: "NEON",
+        engine: "POSTGRES",
+      })
+    )
     .exhaustive();
 };
 

+ 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);
 

+ 1 - 2
dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx

@@ -11,7 +11,6 @@ import Text from "components/porter/Text";
 import { readableDate } from "shared/string_utils";
 
 import { useDatastoreContext } from "./DatabaseContextProvider";
-import { getDatastoreIcon } from "./icons";
 import EngineTag from "./tags/EngineTag";
 
 const DatabaseHeader: React.FC = () => {
@@ -22,7 +21,7 @@ const DatabaseHeader: React.FC = () => {
       <Container row style={{ width: "100%" }}>
         <Container row spaced style={{ width: "100%" }}>
           <Container row>
-            <Icon src={getDatastoreIcon(datastore.type)} height={"25px"} />
+            <Icon src={datastore.template.icon} height={"25px"} />
             <Spacer inline x={1} />
             <Text size={21}>{datastore.name}</Text>
             <Spacer inline x={1} />

+ 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 - 1
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,6 +108,15 @@ 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) {
@@ -107,10 +131,15 @@ const DatastoreFormContextProvider: React.FC<
     }
   });
 
+  if (!currentProject) {
+    return <Loading />;
+  }
+
   return (
     <DatastoreFormContext.Provider
       value={{
         updateDatastoreButtonProps,
+        projectId: currentProject.id,
       }}
     >
       <Wrapper>
@@ -118,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}
     />
   );

+ 128 - 5
dashboard/src/main/home/database-dashboard/constants.ts

@@ -13,6 +13,7 @@ import {
   DATASTORE_TYPE_ELASTICACHE,
   DATASTORE_TYPE_MANAGED_POSTGRES,
   DATASTORE_TYPE_MANAGED_REDIS,
+  DATASTORE_TYPE_NEON,
   DATASTORE_TYPE_RDS,
   type DatastoreEngine,
   type DatastoreTemplate,
@@ -21,9 +22,15 @@ import {
 import awsRDS from "assets/amazon-rds.png";
 import awsElastiCache from "assets/aws-elasticache.png";
 import infra from "assets/cluster.svg";
+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",
@@ -103,7 +110,6 @@ export const DATASTORE_TEMPLATE_AWS_RDS: DatastoreTemplate = Object.freeze({
       storageGigabytes: 2048,
     },
   ],
-  formTitle: "Create an RDS PostgreSQL instance",
   creationStateProgression: [
     DATASTORE_STATE_CREATING,
     DATASTORE_STATE_CONFIGURING_LOG_EXPORTS,
@@ -117,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",
@@ -145,7 +168,6 @@ export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({
       storageGigabytes: 256,
     },
   ],
-  formTitle: "Create an Aurora PostgreSQL instance",
   creationStateProgression: [
     DATASTORE_STATE_CREATING,
     DATASTORE_STATE_AVAILABLE,
@@ -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({
@@ -205,7 +244,6 @@ export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate =
         storageGigabytes: 0,
       },
     ],
-    formTitle: "Create an ElastiCache Redis instance",
     creationStateProgression: [
       DATASTORE_STATE_CREATING,
       DATASTORE_STATE_MODIFYING,
@@ -218,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({
@@ -246,7 +301,6 @@ export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate =
         storageGigabytes: 2,
       },
     ],
-    formTitle: "Create an ElastiCache Memcached instance",
     creationStateProgression: [
       DATASTORE_STATE_CREATING,
       DATASTORE_STATE_AVAILABLE,
@@ -256,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({
@@ -284,7 +355,6 @@ export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate =
         storageGigabytes: 2,
       },
     ],
-    formTitle: "Create a managed PostgreSQL instance",
     creationStateProgression: [
       DATASTORE_STATE_CREATING,
       DATASTORE_STATE_AVAILABLE,
@@ -294,12 +364,65 @@ 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({
+  name: "Neon",
+  displayName: "Neon",
+  highLevelType: DATASTORE_ENGINE_POSTGRES,
+  type: DATASTORE_TYPE_NEON,
+  engine: DATASTORE_ENGINE_POSTGRES,
+  supportedEngineVersions: [],
+  icon: neon as string,
+  description:
+    "A fully managed serverless Postgres. Neon separates storage and compute to offer autoscaling, branching, and bottomless storage.",
+  disabled: true,
+  instanceTiers: [],
+  creationStateProgression: [
+    DATASTORE_STATE_CREATING,
+    DATASTORE_STATE_AVAILABLE,
+  ],
+  deletionStateProgression: [
+    DATASTORE_STATE_AWAITING_DELETION,
+    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[] = [
   DATASTORE_TEMPLATE_AWS_RDS,
   DATASTORE_TEMPLATE_AWS_AURORA,
   DATASTORE_TEMPLATE_AWS_ELASTICACHE,
   DATASTORE_TEMPLATE_MANAGED_POSTGRES,
   DATASTORE_TEMPLATE_MANAGED_REDIS,
+  DATASTORE_TEMPLATE_NEON,
 ];

+ 16 - 2
dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx

@@ -1,12 +1,26 @@
-import React from "react";
+import React, { useContext } from "react";
+import { match } from "ts-pattern";
+
+import Loading from "components/Loading";
+
+import { Context } from "shared/Context";
 
 import DatastoreFormContextProvider from "../DatastoreFormContextProvider";
 import DatastoreForm from "./DatastoreForm";
+import SandboxDatastoreForm from "./SandboxDatastoreForm";
 
 const CreateDatastore: React.FC = () => {
+  const { currentProject } = useContext(Context);
+
+  if (!currentProject) {
+    return <Loading />;
+  }
   return (
     <DatastoreFormContextProvider>
-      <DatastoreForm />
+      {match(currentProject)
+        .with({ sandbox_enabled: true }, () => <SandboxDatastoreForm />)
+        .with({ sandbox_enabled: false }, () => <DatastoreForm />)
+        .exhaustive()}
     </DatastoreFormContextProvider>
   );
 };

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

@@ -378,7 +378,7 @@ const DatastoreForm: React.FC = () => {
                             database_name: watchDbName,
                           }
                     }
-                    engine={template.engine}
+                    template={template}
                   />
                 </>
               )}

+ 236 - 0
dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx

@@ -0,0 +1,236 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Back from "components/porter/Back";
+import BlockSelect, {
+  type BlockSelectOption,
+} from "components/porter/BlockSelect";
+import Button from "components/porter/Button";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Selector from "components/porter/Selector";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { type DatastoreTemplate, type DbFormData } from "lib/databases/types";
+import { useClusterList } from "lib/hooks/useCluster";
+
+import { valueExists } from "shared/util";
+import database from "assets/database.svg";
+
+import {
+  DATASTORE_ENGINE_POSTGRES,
+  DATASTORE_ENGINE_REDIS,
+  DATASTORE_TEMPLATE_NEON,
+  SUPPORTED_DATASTORE_TEMPLATES,
+} from "../constants";
+import { useDatastoreFormContext } from "../DatastoreFormContextProvider";
+
+const SandboxDatastoreForm: React.FC = () => {
+  const [currentStep, setCurrentStep] = useState(0);
+  const [template, setTemplate] = useState<DatastoreTemplate | undefined>(
+    undefined
+  );
+
+  const { clusters } = useClusterList();
+
+  const {
+    setValue,
+    formState: { errors },
+    register,
+    watch,
+  } = useFormContext<DbFormData>();
+  const watchClusterId = watch("clusterId", 0);
+  const watchEngine = watch("engine", "UNKNOWN");
+
+  const { updateDatastoreButtonProps } = useDatastoreFormContext();
+
+  const availableEngines: BlockSelectOption[] = useMemo(() => {
+    return [
+      DATASTORE_ENGINE_POSTGRES,
+      {
+        ...DATASTORE_ENGINE_REDIS,
+        disabledOpts: {
+          tooltipText: "Coming soon!",
+        },
+      },
+    ];
+  }, [watchClusterId]);
+
+  const availableHostTypes: BlockSelectOption[] = useMemo(() => {
+    const options = [DATASTORE_TEMPLATE_NEON].filter(
+      (t) => t.highLevelType.name === watchEngine
+    );
+    return options;
+  }, [watchEngine]);
+
+  useEffect(() => {
+    if (clusters.length > 0) {
+      setValue("clusterId", clusters[0].id);
+    }
+  }, [JSON.stringify(clusters)]);
+
+  return (
+    <Div>
+      <StyledConfigureTemplate>
+        <Back to="/datastores" />
+        <DashboardHeader
+          prefix={<Icon src={database} />}
+          title={"Create a new datastore"}
+          capitalize={false}
+          disableLineBreak
+        />
+        <DarkMatter />
+        <VerticalSteps
+          steps={[
+            <>
+              <Text size={16}>Datastore type</Text>
+              <Spacer y={0.5} />
+              <Controller
+                name="engine"
+                render={({ field: { value, onChange } }) => (
+                  <BlockSelect
+                    options={availableEngines}
+                    selectedOption={availableEngines.find(
+                      (e) => e.name === value
+                    )}
+                    setOption={(opt) => {
+                      onChange(opt.name);
+                      setValue("workloadType", "unspecified");
+                      setTemplate(undefined);
+                      setCurrentStep(1);
+                    }}
+                  />
+                )}
+              />
+            </>,
+            <>
+              <Text size={16}>Datastore name</Text>
+              {watchEngine !== "UNKNOWN" && (
+                <>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Lowercase letters, numbers, and &quot;-&quot; only.
+                  </Text>
+                  <Spacer y={0.5} />
+                  <ControlledInput
+                    placeholder="ex: academic-sophon-db"
+                    type="text"
+                    width="300px"
+                    error={errors.name?.message}
+                    {...register("name")}
+                    onChange={(e) => {
+                      setValue("name", e.target.value);
+                      setCurrentStep(Math.max(2, currentStep));
+                    }}
+                  />
+                  {clusters.length > 1 && (
+                    <>
+                      <Spacer y={1} />
+                      <Selector<string>
+                        activeValue={watchClusterId.toString()}
+                        width="300px"
+                        options={clusters.map((c) => ({
+                          value: c.id.toString(),
+                          label: c.vanity_name,
+                          key: c.id.toString(),
+                        }))}
+                        setActiveValue={(value: string) => {
+                          setValue("clusterId", parseInt(value));
+                          setValue("workloadType", "unspecified");
+                          setCurrentStep(2);
+                        }}
+                        label={"Cluster"}
+                      />
+                    </>
+                  )}
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Hosting option</Text>
+              {currentStep >= 2 && (
+                <>
+                  <Spacer y={0.5} />
+                  <BlockSelect
+                    options={availableHostTypes}
+                    selectedOption={availableHostTypes.find(
+                      (a) => a.name === template?.name
+                    )}
+                    setOption={(opt) => {
+                      const templateMatch = SUPPORTED_DATASTORE_TEMPLATES.find(
+                        (t) => t.name === opt.name
+                      );
+                      if (!templateMatch) {
+                        return;
+                      }
+                      setTemplate(templateMatch);
+                      match(templateMatch).with(
+                        {
+                          name: DATASTORE_TEMPLATE_NEON.name,
+                        },
+                        () => {
+                          setValue("config.type", "neon");
+                        }
+                      );
+                      setCurrentStep(4);
+                    }}
+                  />
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Create datastore instance</Text>
+              <Spacer y={0.5} />
+              <Button
+                type="submit"
+                status={updateDatastoreButtonProps.status}
+                loadingText={updateDatastoreButtonProps.loadingText}
+                disabled={updateDatastoreButtonProps.isDisabled}
+              >
+                Create
+              </Button>
+            </>,
+          ].filter(valueExists)}
+          currentStep={currentStep}
+        />
+      </StyledConfigureTemplate>
+    </Div>
+  );
+};
+
+export default SandboxDatastoreForm;
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 30px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 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;

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

@@ -0,0 +1,70 @@
+import React 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 { useDatastoreContext } from "../DatabaseContextProvider";
+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();
+
+  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>
+      </div>
+    </ConnectTabContainer>
+  );
+};
+
+export default PublicDatastoreConnectTab;
+
+const ConnectTabContainer = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
+`;

+ 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..."}
       >

+ 9 - 1
dashboard/src/shared/api.tsx

@@ -2061,6 +2061,13 @@ const getSlackIntegrations = baseApi<{}, { id: number }>(
   }
 );
 
+const getNeonIntegrations = baseApi<{}, { projectId: number }>(
+  "GET",
+  ({ projectId }) => {
+    return `/api/projects/${projectId}/neon-integrations`;
+  }
+);
+
 const getRevisions = baseApi<
   {},
   { id: number; cluster_id: number; namespace: string; name: string }
@@ -2882,7 +2889,7 @@ const getDatastoreCredential = baseApi<
 const updateDatastore = baseApi<
   {
     name: string;
-    type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS";
+    type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS" | "NEON";
     engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
 
     values: any;
@@ -3887,6 +3894,7 @@ export default {
   getReleaseSteps,
   getRepoIntegrations,
   getSlackIntegrations,
+  getNeonIntegrations,
   getRepos,
   getRevisions,
   getTemplateInfo,

+ 1 - 1
go.mod

@@ -87,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
 }
+
+// DecryptNeonIntegration 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
+}