Sfoglia il codice sorgente

Merge branch 'block-on-app-create' of github.com:porter-dev/porter into block-on-app-create

jusrhee 2 anni fa
parent
commit
9901bdf81c

+ 6 - 2
api/server/handlers/billing/customer.go

@@ -41,8 +41,12 @@ func (c *CreateBillingCustomerHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
+	// There is no easy way to pass environment variables to the frontend,
+	// so for now pass via the backend. This is acceptable because the key is
+	// meant to be public
+	publishableKey := c.Config().BillingManager.GetPublishableKey()
 	if proj.BillingID != "" {
-		c.WriteResult(w, r, "")
+		c.WriteResult(w, r, publishableKey)
 		return
 	}
 
@@ -63,5 +67,5 @@ func (c *CreateBillingCustomerHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
-	c.WriteResult(w, r, "")
+	c.WriteResult(w, r, publishableKey)
 }

+ 4 - 3
api/server/shared/config/env/envconfs.go

@@ -69,9 +69,10 @@ type ServerConf struct {
 	SendgridDeleteProjectTemplateID    string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"`
 	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
-	StripeSecretKey   string `env:"STRIPE_SECRET_KEY"`
-	SlackClientID     string `env:"SLACK_CLIENT_ID"`
-	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
+	StripeSecretKey      string `env:"STRIPE_SECRET_KEY"`
+	StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"`
+	SlackClientID        string `env:"SLACK_CLIENT_ID"`
+	SlackClientSecret    string `env:"SLACK_CLIENT_SECRET"`
 
 	BillingPrivateKey       string `env:"BILLING_PRIVATE_KEY"`
 	BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`

+ 2 - 3
api/server/shared/config/loader/loader.go

@@ -58,13 +58,13 @@ func sharedInit() {
 	InstanceEnvConf, _ = envloader.FromEnv()
 
 	InstanceDB, err = adapter.New(InstanceEnvConf.DBConf)
-
 	if err != nil {
 		panic(err)
 	}
 
 	InstanceBillingManager = &billing.StripeBillingManager{
-		StripeSecretKey: InstanceEnvConf.ServerConf.StripeSecretKey,
+		StripeSecretKey:      InstanceEnvConf.ServerConf.StripeSecretKey,
+		StripePublishableKey: InstanceEnvConf.ServerConf.StripePublishableKey,
 	}
 }
 
@@ -123,7 +123,6 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 			Insecure:          envConf.ServerConf.CookieInsecure,
 		},
 	)
-
 	if err != nil {
 		return nil, err
 	}

+ 25 - 0
dashboard/src/lib/hooks/useStripe.tsx

@@ -27,6 +27,10 @@ type TCheckHasPaymentEnabled = {
   refetchPaymentEnabled: any;
 };
 
+type TGetPublishableKey = {
+  publishableKey: string;
+};
+
 export const usePaymentMethods = (): TUsePaymentMethod => {
   const { user, currentProject } = useContext(Context);
 
@@ -144,3 +148,24 @@ export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
     refetchPaymentEnabled: paymentEnabledReq.refetch,
   };
 };
+
+export const usePublishableKey = (): TGetPublishableKey => {
+  const { user, currentProject } = useContext(Context);
+
+  // Fetch list of payment methods
+  const keyReq = useQuery(["getKey", currentProject?.id], async () => {
+    if (!currentProject?.id || currentProject.id === -1) {
+      return;
+    }
+    const res = await api.checkBillingCustomerExists(
+      "<token>",
+      { user_email: user?.email },
+      { project_id: currentProject?.id }
+    );
+    return res.data;
+  });
+
+  return {
+    publishableKey: keyReq.data,
+  };
+};

+ 188 - 187
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -1,44 +1,43 @@
 import React, {
-  useEffect,
-  useState,
+  useCallback,
   useContext,
+  useEffect,
   useMemo,
-  useCallback
+  useState,
 } from "react";
-import styled from "styled-components";
 import _ from "lodash";
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import Fieldset from "components/porter/Fieldset";
+import PorterLink from "components/porter/Link";
+import SearchBar from "components/porter/SearchBar";
+import ShowIntercomButton from "components/porter/ShowIntercomButton";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Toggle from "components/porter/Toggle";
+import { useAuthState } from "main/auth/context";
 
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { search } from "shared/search";
+import { readableDate } from "shared/string_utils";
 import addOnGrad from "assets/add-on-grad.svg";
-import time from "assets/time.png";
-import healthy from "assets/status-healthy.png";
 import grid from "assets/grid.png";
 import list from "assets/list.png";
 import notFound from "assets/not-found.png";
-
-import { Context } from "shared/Context";
-import { search } from "shared/search";
-import api from "shared/api";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
+import healthy from "assets/status-healthy.png";
+import time from "assets/time.png";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 
-import Container from "components/porter/Container";
-import Button from "components/porter/Button";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import SearchBar from "components/porter/SearchBar";
-import Toggle from "components/porter/Toggle";
-import { readableDate } from "shared/string_utils";
-import Loading from "components/Loading";
-import { Link } from "react-router-dom";
-import Fieldset from "components/porter/Fieldset";
-import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
-import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
-import { useAuthState } from "main/auth/context";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
-
-type Props = {
-};
+type Props = {};
 
 export const RestrictedNamespaces = [
   "ack-system",
@@ -61,9 +60,7 @@ const templateBlacklist = [
   "redis-managed",
 ];
 
-const AddOnDashboard: React.FC<Props> = ({
-}) => {
-
+const AddOnDashboard: React.FC<Props> = ({}) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [addOns, setAddOns] = useState([]);
   const [searchValue, setSearchValue] = useState("");
@@ -78,14 +75,10 @@ const AddOnDashboard: React.FC<Props> = ({
       );
     });
 
-    const filteredBySearch = search(
-      filtered ?? [],
-      searchValue,
-      {
-        keys: ["name", "chart.metadata.name"],
-        isCaseSensitive: false,
-      }
-    );
+    const filteredBySearch = search(filtered ?? [], searchValue, {
+      keys: ["name", "chart.metadata.name"],
+      isCaseSensitive: false,
+    });
 
     return _.sortBy(filteredBySearch);
   }, [addOns, searchValue]);
@@ -120,7 +113,7 @@ const AddOnDashboard: React.FC<Props> = ({
       setAddOns(charts);
     } catch (err) {
       setIsLoading(false);
-    };
+    }
   };
 
   useEffect(() => {
@@ -130,22 +123,25 @@ const AddOnDashboard: React.FC<Props> = ({
     }
   }, [currentCluster, currentProject]);
 
-  const getExpandedChartLinkURL = useCallback((x: any) => {
-    const params = new Proxy(new URLSearchParams(window.location.search), {
-      get: (searchParams, prop: string) => searchParams.get(prop),
-    });
-    const cluster = currentCluster?.name;
-    const route = `/applications/${cluster}/${x.namespace}/${x.name}`;
-    const newParams = {
-      // @ts-expect-error
-      project_id: params.project_id,
-      closeChartRedirectUrl: '/addons',
-    };
-    const newURLSearchParams = new URLSearchParams(
-      _.omitBy(newParams, _.isNil)
-    );
-    return `${route}?${newURLSearchParams.toString()}`;
-  }, [currentCluster]);
+  const getExpandedChartLinkURL = useCallback(
+    (x: any) => {
+      const params = new Proxy(new URLSearchParams(window.location.search), {
+        get: (searchParams, prop: string) => searchParams.get(prop),
+      });
+      const cluster = currentCluster?.name;
+      const route = `/applications/${cluster}/${x.namespace}/${x.name}`;
+      const newParams = {
+        // @ts-expect-error
+        project_id: params.project_id,
+        closeChartRedirectUrl: "/addons",
+      };
+      const newURLSearchParams = new URLSearchParams(
+        _.omitBy(newParams, _.isNil)
+      );
+      return `${route}?${newURLSearchParams.toString()}`;
+    },
+    [currentCluster]
+  );
 
   return (
     <StyledAppDashboard>
@@ -158,136 +154,140 @@ const AddOnDashboard: React.FC<Props> = ({
       />
       {currentCluster?.status === "UPDATING_UNAVAILABLE" ? (
         <ClusterProvisioningPlaceholder />
-      ) : (
-        currentProject?.sandbox_enabled ? (
+      ) : currentProject?.sandbox_enabled ? (
+        <DashboardPlaceholder>
+          <Text size={16}>Add-ons are not enabled for sandbox users</Text>
+          <Spacer y={0.5} />
+          <Text color={"helper"}>
+            Eject to your own cloud account to enable Porter add-ons.
+          </Text>
+          <Spacer y={1} />
+          <PorterLink to="https://docs.porter.run/other/eject">
+            <Button alt height="35px">
+              Request ejection
+            </Button>
+          </PorterLink>
+        </DashboardPlaceholder>
+      ) : addOns.length === 0 ||
+        (filteredAddOns.length === 0 && searchValue === "") ? (
+        isLoading ? (
+          <Loading offset="-150px" />
+        ) : (
           <DashboardPlaceholder>
-            <Text size={16}>Add-ons are not enabled for sandbox users</Text>
+            <Text size={16}>No add-ons have been created yet</Text>
             <Spacer y={0.5} />
             <Text color={"helper"}>
-              Eject to your own cloud account to enable Porter add-ons.
+              Deploy from our suite of curated add-ons.
             </Text>
             <Spacer y={1} />
-            <ShowIntercomButton
-              alt
-              message="I would like to eject to my own cloud account"
-              height="35px"
-            >
-              Request ejection
-            </ShowIntercomButton>
+            <Link to="/addons/new">
+              <Button alt onClick={() => {}} height="35px">
+                Deploy a new add-on <Spacer inline x={1} />{" "}
+                <i className="material-icons" style={{ fontSize: "18px" }}>
+                  east
+                </i>
+              </Button>
+            </Link>
           </DashboardPlaceholder>
-        ) : (addOns.length === 0 || (filteredAddOns.length === 0 && searchValue === "")) ? (
-
-          isLoading ?
-            (<Loading offset="-150px" />) : (
-              <DashboardPlaceholder>
-                <Text size={16}>
-                  No add-ons have been created yet
-                </Text>
-                <Spacer y={0.5} />
-                <Text color={"helper"}>
-                  Deploy from our suite of curated add-ons.
+        )
+      ) : (
+        <>
+          <Container row spaced>
+            <SearchBar
+              value={searchValue}
+              setValue={setSearchValue}
+              placeholder="Search add-ons . . ."
+              width="100%"
+            />
+            <Spacer inline x={2} />
+            <Toggle
+              items={[
+                { label: <ToggleIcon src={grid} />, value: "grid" },
+                { label: <ToggleIcon src={list} />, value: "list" },
+              ]}
+              active={view}
+              setActive={setView}
+            />
+            <Spacer inline x={2} />
+            <Link to="/addons/new">
+              <Button onClick={() => {}} height="30px" width="130px">
+                <I className="material-icons">add</I> New add-on
+              </Button>
+            </Link>
+          </Container>
+          <Spacer y={1} />
+
+          {filteredAddOns.length === 0 ? (
+            <Fieldset>
+              <Container row>
+                <PlaceholderIcon src={notFound} />
+                <Text color="helper">
+                  {searchValue === ""
+                    ? "No add-ons have been deployed yet."
+                    : "No matching add-ons were found."}
                 </Text>
-                <Spacer y={1} />
-                <Link to="/addons/new">
-                  <Button alt onClick={() => { }} height="35px" >
-                    Deploy a new add-on <Spacer inline x={1} /> <i className="material-icons" style={{ fontSize: '18px' }}>east</i>
-                  </Button>
-                </Link>
-              </DashboardPlaceholder>
-            )
-        ) : (
-          <>
-            <Container row spaced>
-              <SearchBar
-                value={searchValue}
-                setValue={setSearchValue}
-                placeholder="Search add-ons . . ."
-                width="100%"
-              />
-              <Spacer inline x={2} />
-              <Toggle
-                items={[
-                  { label: <ToggleIcon src={grid} />, value: "grid" },
-                  { label: <ToggleIcon src={list} />, value: "list" },
-                ]}
-                active={view}
-                setActive={setView}
-              />
-              <Spacer inline x={2} />
-              <Link to="/addons/new">
-                <Button onClick={() => { }} height="30px" width="130px">
-                  <I className="material-icons">add</I> New add-on
-                </Button>
-              </Link>
-            </Container>
-            <Spacer y={1} />
-
-            {filteredAddOns.length === 0 ? (
-              <Fieldset>
-                <Container row>
-                  <PlaceholderIcon src={notFound} />
-                  <Text color="helper">{searchValue === "" ? "No add-ons have been deployed yet." : "No matching add-ons were found."}</Text>
-                </Container>
-              </Fieldset>
-            ) : (isLoading ? <Loading offset="-150px" /> : view === "grid" ? (
-              <GridList>
-                {(filteredAddOns ?? []).map((app: any, i: number) => {
-                  return (
-                    <Block to={getExpandedChartLinkURL(app)} key={i}>
-                      <Container row>
-                        <Icon
-                          src={
-                            hardcodedIcons[app.chart.metadata.name] ||
-                            app.chart.metadata.icon
-                          }
-                        />
-                        <Text size={14}>{app.name}</Text>
-                        <Spacer inline x={2} />
-                      </Container>
-                      <StatusIcon src={healthy} />
-                      <Container row>
-                        <SmallIcon opacity="0.4" src={time} />
-                        <Text size={13} color="#ffffff44">
-                          {readableDate(app.info.last_deployed)}
-                        </Text>
-                      </Container>
-                    </Block>
-                  );
-                })}
-              </GridList>
-            ) : (
-              <List>
-                {(filteredAddOns ?? []).map((app: any, i: number) => {
-                  return (
-                    <Row to={getExpandedChartLinkURL(app)} key={i}>
-                      <Container row>
-                        <MidIcon
-                          src={
-                            hardcodedIcons[app.chart.metadata.name] ||
-                            app.chart.metadata.icon
-                          }
-                        />
-                        <Text size={14}>{app.name}</Text>
-                        <Spacer inline x={1} />
-                        <MidIcon src={healthy} height="16px" />
-                      </Container>
-                      <Spacer height="15px" />
-                      <Container row>
-                        <SmallIcon opacity="0.4" src={time} />
-                        <Text size={13} color="#ffffff44">
-                          {readableDate(app.info.last_deployed)}
-                        </Text>
-                      </Container>
-                    </Row>
-                  );
-                })}
-              </List>
-            )
-            )}
-          </>
-        ))}
+              </Container>
+            </Fieldset>
+          ) : isLoading ? (
+            <Loading offset="-150px" />
+          ) : view === "grid" ? (
+            <GridList>
+              {(filteredAddOns ?? []).map((app: any, i: number) => {
+                return (
+                  <Block to={getExpandedChartLinkURL(app)} key={i}>
+                    <Container row>
+                      <Icon
+                        src={
+                          hardcodedIcons[app.chart.metadata.name] ||
+                          app.chart.metadata.icon
+                        }
+                      />
+                      <Text size={14}>{app.name}</Text>
+                      <Spacer inline x={2} />
+                    </Container>
+                    <StatusIcon src={healthy} />
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={time} />
+                      <Text size={13} color="#ffffff44">
+                        {readableDate(app.info.last_deployed)}
+                      </Text>
+                    </Container>
+                  </Block>
+                );
+              })}
+            </GridList>
+          ) : (
+            <List>
+              {(filteredAddOns ?? []).map((app: any, i: number) => {
+                return (
+                  <Row to={getExpandedChartLinkURL(app)} key={i}>
+                    <Container row>
+                      <MidIcon
+                        src={
+                          hardcodedIcons[app.chart.metadata.name] ||
+                          app.chart.metadata.icon
+                        }
+                      />
+                      <Text size={14}>{app.name}</Text>
+                      <Spacer inline x={1} />
+                      <MidIcon src={healthy} height="16px" />
+                    </Container>
+                    <Spacer height="15px" />
+                    <Container row>
+                      <SmallIcon opacity="0.4" src={time} />
+                      <Text size={13} color="#ffffff44">
+                        {readableDate(app.info.last_deployed)}
+                      </Text>
+                    </Container>
+                  </Row>
+                );
+              })}
+            </List>
+          )}
+        </>
+      )}
       <Spacer y={5} />
-    </StyledAppDashboard >
+    </StyledAppDashboard>
   );
 };
 
@@ -299,12 +299,13 @@ const PlaceholderIcon = styled.img`
   opacity: 0.65;
 `;
 
-const Row = styled(Link) <{ isAtBottom?: boolean }>`
+const Row = styled(Link)<{ isAtBottom?: boolean }>`
   cursor: pointer;
   display: block;
   padding: 15px;
-  border-bottom: ${props => props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${props => props.theme.clickable.bg};
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;
@@ -335,14 +336,14 @@ const Icon = styled.img`
 `;
 
 const MidIcon = styled.img<{ height?: string }>`
-  height: ${props => props.height || "18px"};
+  height: ${(props) => props.height || "18px"};
   margin-right: 11px;
 `;
 
 const SmallIcon = styled.img<{ opacity?: string }>`
   margin-left: 2px;
   height: 14px;
-  opacity: ${props => props.opacity || 1};
+  opacity: ${(props) => props.opacity || 1};
   margin-right: 10px;
 `;
 
@@ -353,10 +354,10 @@ const Block = styled(Link)`
   justify-content: space-between;
   cursor: pointer;
   padding: 20px;
-  color: ${props => props.theme.text.primary};
+  color: ${(props) => props.theme.text.primary};
   position: relative;
   border-radius: 5px;
-  background: ${props => props.theme.clickable.bg};
+  background: ${(props) => props.theme.clickable.bg};
   border: 1px solid #494b4f;
   :hover {
     border: 1px solid #7a7b80;
@@ -398,5 +399,5 @@ const CentralContainer = styled.div`
   display: flex;
   flex-direction: column;
   justify-content: left;
-  align-items: left;   
-`;
+  align-items: left;
+`;

+ 13 - 12
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -4,7 +4,12 @@ import styled from "styled-components";
 
 import AzureProvisionerSettings from "components/AzureProvisionerSettings";
 import GCPProvisionerSettings from "components/GCPProvisionerSettings";
+import Button from "components/porter/Button";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import Image from "components/porter/Image";
+import PorterLink from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 import ProvisionerSettings from "components/ProvisionerSettings";
 import TabSelector from "components/TabSelector";
 
@@ -23,10 +28,6 @@ import Metrics from "./Metrics";
 import { NamespaceList } from "./NamespaceList";
 import NodeList from "./NodeList";
 import ProvisionerStatus from "./ProvisionerStatus";
-import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
-import Text from "components/porter/Text";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
-import Image from "components/porter/Image";
 
 type TabEnum =
   | "nodes"
@@ -188,19 +189,19 @@ export const Dashboard: React.FunctionComponent = () => {
     if (context.currentProject?.sandbox_enabled) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>Infrastructure settings are not enabled for sandbox users</Text>
+          <Text size={16}>
+            Infrastructure settings are not enabled for sandbox users
+          </Text>
           <Spacer y={0.5} />
           <Text color={"helper"}>
             Eject to your own cloud account to enable managed infrastructure.
           </Text>
           <Spacer y={1} />
-          <ShowIntercomButton
-            alt
-            message="I would like to eject to my own cloud account"
-            height="35px"
-          >
-            Request ejection
-          </ShowIntercomButton>
+          <PorterLink to="https://docs.porter.run/other/eject">
+            <Button alt height="35px">
+              Request ejection
+            </Button>
+          </PorterLink>
         </DashboardPlaceholder>
       );
     } else if (context.currentProject?.capi_provisioner_enabled) {

+ 50 - 30
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -1,25 +1,26 @@
 import React, { Component, useContext, useEffect, useState } from "react";
+import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
 
-import sliders from "assets/env-groups.svg";
+import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import PorterButton from "components/porter/Button";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import PorterLink from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 
+import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { Context } from "shared/Context";
-import { ClusterType } from "shared/types";
+import { getQueryParam, pushFiltered, pushQueryParams } from "shared/routing";
+import { type ClusterType } from "shared/types";
+import sliders from "assets/env-groups.svg";
 
 import DashboardHeader from "../DashboardHeader";
 import { NamespaceSelector } from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
-import EnvGroupList from "./EnvGroupList";
 import CreateEnvGroup from "./CreateEnvGroup";
+import EnvGroupList from "./EnvGroupList";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
-import { RouteComponentProps, withRouter } from "react-router";
-import { getQueryParam, pushQueryParams, pushFiltered } from "shared/routing";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
-import Spacer from "components/porter/Spacer";
-import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
-import Text from "components/porter/Text";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
 
 type PropsType = RouteComponentProps &
   WithAuthProps & {
@@ -52,7 +53,9 @@ const EnvGroupDashboard = (props: PropsType) => {
   const setNamespace = (namespace: string) => {
     setState((state) => ({ ...state, namespace }));
     pushQueryParams(props, {
-      namespace: currentProject.simplified_view_enabled ? ("porter-env-group") : (namespace ?? "ALL"),
+      namespace: currentProject.simplified_view_enabled
+        ? "porter-env-group"
+        : namespace ?? "ALL",
     });
   };
 
@@ -83,31 +86,32 @@ const EnvGroupDashboard = (props: PropsType) => {
 
   const renderBody = () => {
     if (props.currentCluster.status === "UPDATING_UNAVAILABLE") {
-      return <ClusterProvisioningPlaceholder />
+      return <ClusterProvisioningPlaceholder />;
     }
 
     if (currentProject?.sandbox_enabled) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>Environment groups are not enabled for sandbox users</Text>
+          <Text size={16}>
+            Environment groups are not enabled for sandbox users
+          </Text>
           <Spacer y={0.5} />
           <Text color={"helper"}>
             Eject to your own cloud account to enable environment groups.
           </Text>
           <Spacer y={1} />
-          <ShowIntercomButton
-            alt
-            message="I would like to eject to my own cloud account"
-            height="35px"
-          >
-            Request ejection
-          </ShowIntercomButton>
+          <PorterLink to="https://docs.porter.run/other/eject">
+            <PorterButton alt height="35px">
+              Request ejection
+            </PorterButton>
+          </PorterLink>
         </DashboardPlaceholder>
       );
     }
 
-    const goBack = () =>
+    const goBack = () => {
       setState((state) => ({ ...state, createEnvMode: false }));
+    };
 
     if (state.createEnvMode) {
       return (
@@ -129,10 +133,16 @@ const EnvGroupDashboard = (props: PropsType) => {
                 sortType={state.sortType}
               />
               <Spacer inline width="10px" />
-              {!currentProject.simplified_view_enabled && <NamespaceSelector
-                setNamespace={setNamespace}
-                namespace={currentProject.simplified_view_enabled ? "porter-env-group" : state.namespace}
-              />}
+              {!currentProject.simplified_view_enabled && (
+                <NamespaceSelector
+                  setNamespace={setNamespace}
+                  namespace={
+                    currentProject.simplified_view_enabled
+                      ? "porter-env-group"
+                      : state.namespace
+                  }
+                />
+              )}
             </SortFilterWrapper>
             <Flex>
               {isAuthorizedToAdd && (
@@ -145,7 +155,11 @@ const EnvGroupDashboard = (props: PropsType) => {
 
           <EnvGroupList
             currentCluster={props.currentCluster}
-            namespace={currentProject?.simplified_view_enabled ? "porter-env-group" : state.namespace}
+            namespace={
+              currentProject?.simplified_view_enabled
+                ? "porter-env-group"
+                : state.namespace
+            }
             sortType={state.sortType}
             setExpandedEnvGroup={setExpandedEnvGroup}
           />
@@ -159,10 +173,16 @@ const EnvGroupDashboard = (props: PropsType) => {
       return (
         <ExpandedEnvGroup
           isAuthorized={props.isAuthorized}
-          namespace={currentProject?.simplified_view_enabled ? "porter-env-group" : (state.expandedEnvGroup?.namespace || state.namespace)}
+          namespace={
+            currentProject?.simplified_view_enabled
+              ? "porter-env-group"
+              : state.expandedEnvGroup?.namespace || state.namespace
+          }
           currentCluster={props.currentCluster}
           envGroup={state.expandedEnvGroup}
-          closeExpanded={() => closeExpanded()}
+          closeExpanded={() => {
+            closeExpanded();
+          }}
         />
       );
     } else {
@@ -237,7 +257,7 @@ const Button = styled.div`
     props.disabled ? "#aaaabbee" : "#616FEEcc"};
   :hover {
     background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "" : "#505edddd"};
+      props.disabled ? "" : "#505edddd"};
   }
 
   > i {

+ 7 - 7
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -4,8 +4,10 @@ import { match } from "ts-pattern";
 
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
 import Loading from "components/Loading";
+import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import PorterLink from "components/porter/Link";
 import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -60,13 +62,11 @@ const PreviewEnvs: React.FC = () => {
             apps.
           </Text>
           <Spacer y={1} />
-          <ShowIntercomButton
-            alt
-            message="I would like to eject to my own cloud account"
-            height="35px"
-          >
-            Request ejection
-          </ShowIntercomButton>
+          <PorterLink to="https://docs.porter.run/other/eject" target="_blank">
+            <Button alt height="35px" target="_blank">
+             Eject to AWS, Azure, or GCP.
+            </Button>
+          </PorterLink>
         </DashboardPlaceholder>
       );
     }

+ 7 - 7
dashboard/src/main/home/compliance-dashboard/ComplianceDashboard.tsx

@@ -2,7 +2,9 @@ import React, { useContext, useState } from "react";
 import styled from "styled-components";
 
 import ClusterProvisioningPlaceholder from "components/ClusterProvisioningPlaceholder";
+import Button from "components/porter/Button";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import PorterLink from "components/porter/Link";
 import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -53,13 +55,11 @@ const ComplianceDashboard: React.FC = () => {
               dashboard.
             </Text>
             <Spacer y={1} />
-            <ShowIntercomButton
-              alt
-              message="I would like to eject to my own cloud account"
-              height="35px"
-            >
-              Request ejection
-            </ShowIntercomButton>
+            <PorterLink to="https://docs.porter.run/other/eject" target="_blank">
+              <Button alt height="35px" target="_blank">
+                Eject to AWS, Azure, or GCP.
+              </Button>
+            </PorterLink>
           </DashboardPlaceholder>
         ) : !currentProject?.soc2_controls_enabled ? (
           <DashboardPlaceholder>

+ 5 - 7
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -94,13 +94,11 @@ const DatabaseDashboard: React.FC = () => {
             datastores.
           </Text>
           <Spacer y={1} />
-          <ShowIntercomButton
-            alt
-            message="I would like to eject to my own cloud account"
-            height="35px"
-          >
-            Request ejection
-          </ShowIntercomButton>
+          <PorterLink to="https://docs.porter.run/other/eject" target="_blank">
+            <Button alt height="35px">
+             Eject to AWS, Azure, or GCP.
+            </Button>
+          </PorterLink>
         </DashboardPlaceholder>
       );
     }

+ 23 - 11
dashboard/src/main/home/env-dashboard/EnvDashboard.tsx

@@ -11,8 +11,8 @@ import Container from "components/porter/Container";
 import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import Fieldset from "components/porter/Fieldset";
 import Image from "components/porter/Image";
+import PorterLink from "components/porter/Link";
 import SearchBar from "components/porter/SearchBar";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
@@ -23,6 +23,7 @@ import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { Context } from "shared/Context";
 import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
+import database from "assets/database.svg";
 import doppler from "assets/doppler.png";
 import envGroupGrad from "assets/env-group-grad.svg";
 import grid from "assets/grid.png";
@@ -30,7 +31,6 @@ import key from "assets/key.svg";
 import list from "assets/list.png";
 import notFound from "assets/not-found.png";
 import time from "assets/time.png";
-import database from "assets/database.svg";
 
 import { envGroupPath } from "../../../shared/util";
 
@@ -92,13 +92,11 @@ const EnvDashboard: React.FC<Props> = (props) => {
             Eject to your own cloud account to enable environment groups.
           </Text>
           <Spacer y={1} />
-          <ShowIntercomButton
-            alt
-            message="I would like to eject to my own cloud account"
-            height="35px"
-          >
-            Request ejection
-          </ShowIntercomButton>
+          <PorterLink to="https://docs.porter.run/other/eject">
+            <Button alt height="35px">
+              Request ejection
+            </Button>
+          </PorterLink>
         </DashboardPlaceholder>
       );
     }
@@ -202,7 +200,13 @@ const EnvDashboard: React.FC<Props> = (props) => {
                 >
                   <Container row>
                     <Image
-                      src={envGroup.type === "doppler" ? doppler : envGroup.type === "datastore" ? database : key}
+                      src={
+                        envGroup.type === "doppler"
+                          ? doppler
+                          : envGroup.type === "datastore"
+                          ? database
+                          : key
+                      }
                       size={20}
                     />
                     <Spacer inline x={0.7} />
@@ -228,7 +232,15 @@ const EnvDashboard: React.FC<Props> = (props) => {
                   key={i}
                 >
                   <Container row>
-                    <Image src={envGroup.type === "doppler" ? doppler : envGroup.type === "datastore" ? database : key} />
+                    <Image
+                      src={
+                        envGroup.type === "doppler"
+                          ? doppler
+                          : envGroup.type === "datastore"
+                          ? database
+                          : key
+                      }
+                    />
                     <Spacer inline x={0.7} />
                     <Text size={14}>{envGroup.name}</Text>
                   </Container>

+ 5 - 8
dashboard/src/main/home/infrastructure-dashboard/ClusterDashboard.tsx

@@ -13,7 +13,6 @@ import Image from "components/porter/Image";
 import PorterLink from "components/porter/Link";
 import SearchBar from "components/porter/SearchBar";
 import Select from "components/porter/Select";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import StatusDot from "components/porter/StatusDot";
 import Tag from "components/porter/Tag";
@@ -86,13 +85,11 @@ const ClusterDashboard: React.FC = () => {
             Eject to your own cloud account to enable infrastructure.
           </Text>
           <Spacer y={1} />
-          <ShowIntercomButton
-            alt
-            message="I would like to eject to my own cloud account"
-            height="35px"
-          >
-            Request ejection
-          </ShowIntercomButton>
+          <PorterLink to="https://docs.porter.run/other/eject">
+            <Button alt height="35px">
+              Request ejection
+            </Button>
+          </PorterLink>
         </DashboardPlaceholder>
       </StyledAppDashboard>
     );

+ 4 - 2
dashboard/src/main/home/modals/BillingModal.tsx

@@ -8,14 +8,16 @@ 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 { usePublishableKey } from "lib/hooks/useStripe";
 
 import backArrow from "assets/back_arrow.png";
 
 import PaymentSetupForm from "./PaymentSetupForm";
 
-const stripePromise = loadStripe(process.env.STRIPE_PUBLISHABLE_KEY || "");
-
 const BillingModal = ({ back, onCreate }) => {
+  const { publishableKey } = usePublishableKey();
+  const stripePromise = loadStripe(publishableKey);
+
   const appearance = {
     variables: {
       colorPrimary: "#aaaabb",

+ 0 - 1
dashboard/src/main/home/modals/PaymentSetupForm.tsx

@@ -36,7 +36,6 @@ const PaymentSetupForm = ({ onCreate }: { onCreate: () => void }) => {
 
     // Create the setup intent in the server
     const clientSecret = await createPaymentMethod();
-    console.log(clientSecret);
 
     // Finally, confirm with Stripe so the payment method is saved
     const { error } = await stripe.confirmSetup({

+ 4 - 1
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -75,7 +75,10 @@ function ProjectSettings(props: any) {
 
     const tabOpts = [];
     tabOpts.push({ value: "manage-access", label: "Manage access" });
-    tabOpts.push({ value: "metadata", label: "Metadata" });
+
+    if (!currentProject?.sandbox_enabled) {
+      tabOpts.push({ value: "metadata", label: "Metadata" });
+    }
 
     if (props.isAuthorized("settings", "", ["get", "delete"])) {
       if (currentProject?.api_tokens_enabled) {

+ 8 - 0
internal/billing/billing.go

@@ -25,6 +25,9 @@ type BillingManager interface {
 
 	// DeletePaymentMethod will remove a payment method for the project in Stripe
 	DeletePaymentMethod(paymentMethodID string) (err error)
+
+	// GetPublishableKey returns the key used to render frontend components for the billing manager
+	GetPublishableKey() (key string)
 }
 
 // NoopBillingManager performs no billing operations
@@ -59,3 +62,8 @@ func (s *NoopBillingManager) CreatePaymentMethod(proj *models.Project) (clientSe
 func (s *NoopBillingManager) DeletePaymentMethod(paymentMethodID string) (err error) {
 	return nil
 }
+
+// GetPublishableKey is a no-op
+func (s *NoopBillingManager) GetPublishableKey() (key string) {
+	return ""
+}

+ 7 - 1
internal/billing/stripe.go

@@ -14,7 +14,8 @@ import (
 // StripeBillingManager interacts with the Stripe API to manage payment methods
 // and customers
 type StripeBillingManager struct {
-	StripeSecretKey string
+	StripeSecretKey      string
+	StripePublishableKey string
 }
 
 // CreateCustomer will create a customer in Stripe only if the project doesn't have a BillingID
@@ -125,3 +126,8 @@ func (s *StripeBillingManager) DeletePaymentMethod(paymentMethodID string) (err
 
 	return nil
 }
+
+// GetPublishableKey is a no-op
+func (s *StripeBillingManager) GetPublishableKey() (key string) {
+	return s.StripePublishableKey
+}

+ 0 - 3
zarf/helm/.dashboardenv

@@ -19,6 +19,3 @@ API_SERVER=http://localhost:8080
 # TRUST_ARN is used with the cloudformation pack, to allow supporting multiple AWS accounts as management accounts. Change MY_AWS_DEV_ACCOUNT_ID to your AWS developer account ID
 
 TRUST_ARN=arn:aws:iam::MY_AWS_DEV_ACCOUNT_ID:role/CAPIManagement
-
-# STRIPE_PUBLISHABLE_KEY is used to create Stripe Web Elements
-STRIPE_PUBLISHABLE_KEY=

+ 3 - 0
zarf/helm/.serverenv

@@ -69,3 +69,6 @@ TELEMETRY_COLLECTOR_URL=otel-collector:4317
 
 # STRIPE_SECRET_KEY is required if billing is enabled
 STRIPE_SECRET_KEY=
+
+# STRIPE_PUBLISHABLE_KEY is used in the frontend to create Stripe Web Elements
+STRIPE_PUBLISHABLE_KEY=