Просмотр исходного кода

Merge pull request #923 from porter-dev/master

Webhook generation -> staging
abelanger5 4 лет назад
Родитель
Сommit
d4dc608e97

+ 1 - 0
cli/cmd/server.go

@@ -202,6 +202,7 @@ func startLocal(
 		"SQL_LITE=true",
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
+		fmt.Sprintf("SERVER_PORT=%d", port),
 		"REDIS_ENABLED=false",
 	}...)
 

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "v0.2.0"
+var Version string = "v0.5.0"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 55 - 16
dashboard/src/components/SaveButton.tsx

@@ -12,6 +12,8 @@ type PropsType = {
 
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
+  clearPosition?: boolean;
+  statusPosition?: "right" | "left";
 };
 
 type StateType = {};
@@ -21,28 +23,37 @@ export default class SaveButton extends Component<PropsType, StateType> {
     if (this.props.status) {
       if (this.props.status === "successful") {
         return (
-          <StatusWrapper successful={true}>
+          <StatusWrapper position={this.props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "loading") {
         return (
-          <StatusWrapper successful={false}>
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
             <LoadingGif src={loading} />
             <StatusTextWrapper>Updating . . .</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "error") {
         return (
-          <StatusWrapper successful={false}>
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
         );
       } else {
         return (
-          <StatusWrapper successful={false}>
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
           </StatusWrapper>
@@ -50,15 +61,22 @@ export default class SaveButton extends Component<PropsType, StateType> {
       }
     } else if (this.props.helper) {
       return (
-        <StatusWrapper successful={true}>{this.props.helper}</StatusWrapper>
+        <StatusWrapper position={this.props.statusPosition} successful={true}>
+          {this.props.helper}
+        </StatusWrapper>
       );
     }
   };
 
   render() {
     return (
-      <ButtonWrapper makeFlush={this.props.makeFlush}>
-        <div>{this.renderStatus()}</div>
+      <ButtonWrapper
+        makeFlush={this.props.makeFlush}
+        clearPosition={this.props.clearPosition}
+      >
+        {this.props.statusPosition !== "right" && (
+          <div>{this.renderStatus()}</div>
+        )}
         <Button
           disabled={this.props.disabled}
           onClick={this.props.onClick}
@@ -66,6 +84,9 @@ export default class SaveButton extends Component<PropsType, StateType> {
         >
           {this.props.text}
         </Button>
+        {this.props.statusPosition === "right" && (
+          <div>{this.renderStatus()}</div>
+        )}
       </ButtonWrapper>
     );
   }
@@ -87,13 +108,21 @@ const StatusTextWrapper = styled.p`
   margin: 0;
 `;
 
-const StatusWrapper = styled.div`
+const StatusWrapper = styled.div<{
+  successful: boolean;
+  position: "right" | "left";
+}>`
   display: flex;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   color: #ffffff55;
-  margin-right: 25px;
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
   max-width: 500px;
   overflow: hidden;
   text-overflow: ellipsis;
@@ -102,8 +131,7 @@ const StatusWrapper = styled.div`
     font-size: 18px;
     margin-right: 10px;
     float: left;
-    color: ${(props: { successful: boolean }) =>
-      props.successful ? "#4797ff" : "#fcba03"};
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
   }
 
   animation: statusFloatIn 0.5s;
@@ -122,19 +150,30 @@ const StatusWrapper = styled.div`
 `;
 
 const ButtonWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  position: absolute;
-  justify-content: flex-end;
-  ${(props: { makeFlush: boolean }) => {
+  ${(props: { makeFlush: boolean; clearPosition?: boolean }) => {
+    const baseStyles = `
+      display: flex;
+      align-items: center;
+    `;
+
+    if (props.clearPosition) {
+      return baseStyles;
+    }
+
     if (!props.makeFlush) {
       return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
         bottom: 25px;
         right: 27px;
         left: 27px;
       `;
     }
     return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
       bottom: 5px;
       right: 0;
     `;

+ 1 - 1
dashboard/src/main/Main.tsx

@@ -142,7 +142,7 @@ export default class Main extends Component<PropsType, StateType> {
           path="/register"
           render={() => {
             if (!this.state.isLoggedIn) {
-              return <Register authenticate={this.initialize} />;
+              return <Register authenticate={this.authenticate} />;
             } else {
               return <Redirect to="/" />;
             }

+ 206 - 148
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 import yaml from "js-yaml";
@@ -15,9 +15,10 @@ import ImageSelector from "components/image-selector/ImageSelector";
 import SaveButton from "components/SaveButton";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
-import InputRow from "components/values-form/InputRow";
 import _ from "lodash";
 import CopyToClipboard from "components/CopyToClipboard";
+import useAuth from "shared/auth/useAuth";
+import Loading from "components/Loading";
 
 type PropsType = {
   currentChart: ChartType;
@@ -27,201 +28,258 @@ type PropsType = {
   saveButtonText?: string | null;
 };
 
-type StateType = {
-  sourceType: string;
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  saveValuesStatus: string | null;
-  values: string;
-  webhookToken: string;
-  highlightCopyButton: boolean;
-  action: ActionConfigType;
-};
-
-export default class SettingsSection extends Component<PropsType, StateType> {
-  state = {
-    sourceType: "",
-    selectedImageUrl: "",
-    selectedTag: "",
-    values: "",
-    saveValuesStatus: null as string | null,
-    webhookToken: "",
-    highlightCopyButton: false,
-    action: {
-      git_repo: "",
-      image_repo_uri: "",
-      git_repo_id: 0,
-    } as ActionConfigType,
-  };
-
-  // TODO: read in set image from form context instead of config
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-
-    let image = this.props.currentChart.config?.image;
-    this.setState({
-      selectedImageUrl: image?.repository,
-      selectedTag: image?.tag,
-    });
+const SettingsSection: React.FC<PropsType> = ({
+  currentChart,
+  refreshChart,
+  setShowDeleteOverlay,
+  showSource,
+  saveButtonText,
+}) => {
+  const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
+  const [selectedTag, setSelectedTag] = useState<string | null>("");
+  const [saveValuesStatus, setSaveValuesStatus] = useState<string | null>(null);
+  const [highlightCopyButton, setHighlightCopyButton] = useState<boolean>(
+    false
+  );
+  const [webhookToken, setWebhookToken] = useState<string>("");
+  const [
+    createWebhookButtonStatus,
+    setCreateWebhookButtonStatus,
+  ] = useState<string>("");
+  const [loadingWebhookToken, setLoadingWebhookToken] = useState<boolean>(true);
+
+  const [action, setAction] = useState<ActionConfigType>({
+    git_repo: "",
+    image_repo_uri: "",
+    git_repo_id: 0,
+    branch: "",
+  });
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const [isAuthorized] = useAuth();
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setLoadingWebhookToken(true);
+    const image = currentChart?.config?.image;
+    setSelectedImageUrl(image?.repository);
+    setSelectedTag(image?.tag);
 
     api
       .getReleaseToken(
         "<token>",
         {
-          namespace: this.props.currentChart.namespace,
+          namespace: currentChart?.namespace,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
         },
-        { id: currentProject.id, name: this.props.currentChart.name }
+        { id: currentProject.id, name: currentChart?.name }
       )
       .then((res) => {
-        this.setState({
-          action: res.data.git_action_config,
-          webhookToken: res.data.webhook_token,
-        });
-      })
-      .catch(console.log);
-  }
+        if (!isSubscribed) {
+          return;
+        }
 
-  renderWebhookSection = () => {
-    if (!this.props.currentChart?.form?.hasSource) {
-      return;
-    }
+        setAction(res.data.git_action_config);
+        setWebhookToken(res.data.webhook_token);
+      })
+      .catch(console.log)
+      .finally(() => setLoadingWebhookToken(false));
 
-    if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
-      return (
-        <>
-          {this.props.showSource && (
-            <>
-              <Heading>Source Settings</Heading>
-              <Helper>Specify an image tag to use.</Helper>
-              <ImageSelector
-                selectedTag={this.state.selectedTag}
-                selectedImageUrl={this.state.selectedImageUrl}
-                setSelectedImageUrl={(x: string) =>
-                  this.setState({ selectedImageUrl: x })
-                }
-                setSelectedTag={(x: string) =>
-                  this.setState({ selectedTag: x })
-                }
-                forceExpanded={true}
-                disableImageSelect={true}
-              />
-              <Br />
-            </>
-          )}
-          <Heading>Redeploy Webhook</Heading>
-          <Helper>
-            Programmatically deploy by calling this secret webhook.
-          </Helper>
-          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
-            <div>{webhookText}</div>
-            <CopyToClipboard
-              as="i"
-              text={webhookText}
-              onSuccess={() => this.setState({ highlightCopyButton: true })}
-              wrapperProps={{
-                className: "material-icons",
-                onMouseLeave: () =>
-                  this.setState({ highlightCopyButton: false }),
-              }}
-            >
-              content_copy
-            </CopyToClipboard>
-          </Webhook>
-        </>
-      );
-    }
-  };
+    return () => (isSubscribed = false);
+  }, [currentChart, currentCluster, currentProject]);
 
-  handleSubmit = () => {
-    let { currentCluster, setCurrentError, currentProject } = this.context;
-    this.setState({ saveValuesStatus: "loading" });
+  const handleSubmit = async () => {
+    setSaveValuesStatus("loading");
 
-    console.log(this.state.selectedImageUrl);
+    console.log(selectedImageUrl);
 
     let values = {};
-    if (this.state.selectedTag) {
-      _.set(values, "image.repository", this.state.selectedImageUrl);
-      _.set(values, "image.tag", this.state.selectedTag);
+    if (selectedTag) {
+      _.set(values, "image.repository", selectedImageUrl);
+      _.set(values, "image.tag", selectedTag);
     }
 
     // if this is a job, set it to paused
-    if (this.props.currentChart.chart.metadata.name == "job") {
+    if (currentChart?.chart?.metadata?.name == "job") {
       _.set(values, "paused", true);
     }
 
     // Weave in preexisting values and convert to yaml
     let conf = yaml.dump(
       {
-        ...(this.props.currentChart.config as Object),
+        ...(currentChart?.config as Object),
         ...values,
       },
       { forceQuotes: true }
     );
 
-    api
-      .upgradeChartValues(
+    try {
+      await api.upgradeChartValues(
         "<token>",
         {
-          namespace: this.props.currentChart.namespace,
+          namespace: currentChart?.namespace,
           storage: StorageType.Secret,
           values: conf,
         },
         {
           id: currentProject.id,
-          name: this.props.currentChart.name,
+          name: currentChart?.name,
           cluster_id: currentCluster.id,
         }
-      )
-      .then((res) => {
-        this.setState({ saveValuesStatus: "successful" });
-        this.props.refreshChart();
-      })
-      .catch((err) => {
-        let parsedErr =
-          err?.response?.data?.errors && err.response.data.errors[0];
+      );
+      setSaveValuesStatus("successful");
+      refreshChart();
+    } catch (err) {
+      let parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+
+      setSaveValuesStatus(parsedErr);
+      setCurrentError(parsedErr);
+    }
+  };
 
-        if (parsedErr) {
-          err = parsedErr;
+  const handleCreateWebhookToken = async () => {
+    setCreateWebhookButtonStatus("loading");
+    const { id: cluster_id } = currentCluster;
+    const { id: project_id } = currentProject;
+    const { name: chart_name, namespace } = currentChart;
+    try {
+      const res = await api.createWebhookToken(
+        "<token>",
+        {},
+        {
+          project_id,
+          chart_name,
+          namespace,
+          cluster_id,
+          storage: StorageType.Secret,
         }
+      );
+      setCreateWebhookButtonStatus("successful");
+      setTimeout(() => {
+        setAction(res.data.git_action_config);
+        setWebhookToken(res.data.webhook_token);
+      }, 500);
+    } catch (err) {
+      let parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+
+      setCreateWebhookButtonStatus(parsedErr);
+      setCurrentError(parsedErr);
+    }
+  };
 
-        this.setState({
-          saveValuesStatus: parsedErr,
-        });
+  const renderWebhookSection = () => {
+    if (!currentChart?.form?.hasSource) {
+      return;
+    }
 
-        setCurrentError(parsedErr);
-      });
-  };
+    const curlWebhook = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${webhookToken}?commit=YOUR_COMMIT_HASH'`;
+
+    const isAuthorizedToCreateWebhook = isAuthorized("application", "", [
+      "get",
+      "create",
+      "update",
+    ]);
+
+    let buttonStatus = createWebhookButtonStatus;
+
+    if (!isAuthorizedToCreateWebhook) {
+      buttonStatus = "Unauthorized to create webhook token";
+    }
 
-  render() {
     return (
-      <Wrapper>
-        <StyledSettingsSection showSource={this.props.showSource}>
-          {this.renderWebhookSection()}
+      <>
+        {showSource && (
+          <>
+            <Heading>Source Settings</Heading>
+            <Helper>Specify an image tag to use.</Helper>
+            <ImageSelector
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+              setSelectedTag={(x: string) => setSelectedTag(x)}
+              forceExpanded={true}
+              disableImageSelect={true}
+            />
+            <Br />
+          </>
+        )}
+
+        <>
+          <Heading>Redeploy Webhook</Heading>
+          <Helper>
+            Programmatically deploy by calling this secret webhook.
+          </Helper>
+
+          {!loadingWebhookToken && !webhookToken.length && (
+            <SaveButton
+              text={"Create Webhook"}
+              status={buttonStatus}
+              onClick={handleCreateWebhookToken}
+              clearPosition={true}
+              statusPosition={"right"}
+              disabled={!isAuthorizedToCreateWebhook}
+            />
+          )}
+          {webhookToken.length > 0 && (
+            <Webhook copiedToClipboard={highlightCopyButton}>
+              <div>{curlWebhook}</div>
+              <CopyToClipboard
+                as="i"
+                text={curlWebhook}
+                onSuccess={() => setHighlightCopyButton(true)}
+                wrapperProps={{
+                  className: "material-icons",
+                  onMouseLeave: () => setHighlightCopyButton(false),
+                }}
+              >
+                content_copy
+              </CopyToClipboard>
+            </Webhook>
+          )}
+        </>
+      </>
+    );
+  };
+
+  return (
+    <Wrapper>
+      {!loadingWebhookToken ? (
+        <StyledSettingsSection showSource={showSource}>
+          {renderWebhookSection()}
           <Heading>Additional Settings</Heading>
-          <Button
-            color="#b91133"
-            onClick={() => this.props.setShowDeleteOverlay(true)}
-          >
-            Delete {this.props.currentChart.name}
+          <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
+            Delete {currentChart.name}
           </Button>
         </StyledSettingsSection>
-        {this.props.showSource && (
-          <SaveButton
-            text={this.props.saveButtonText || "Save Config"}
-            status={this.state.saveValuesStatus}
-            onClick={this.handleSubmit}
-            makeFlush={true}
-          />
-        )}
-      </Wrapper>
-    );
-  }
-}
+      ) : (
+        <Loading />
+      )}
+      {!loadingWebhookToken && showSource && (
+        <SaveButton
+          text={saveButtonText || "Save Config"}
+          status={saveValuesStatus}
+          onClick={handleSubmit}
+          makeFlush={true}
+        />
+      )}
+    </Wrapper>
+  );
+};
 
-SettingsSection.contextType = Context;
+export default SettingsSection;
 
 const Br = styled.div`
   width: 100%;

+ 16 - 0
dashboard/src/shared/api.tsx

@@ -949,6 +949,21 @@ const getPolicyDocument = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/policy`
 );
 
+const createWebhookToken = baseApi<
+  {},
+  {
+    project_id: number;
+    chart_name: string;
+    namespace: string;
+    cluster_id: number;
+    storage: StorageType;
+  }
+>(
+  "POST",
+  ({ project_id, chart_name, namespace, cluster_id, storage }) =>
+    `/api/projects/${project_id}/releases/${chart_name}/webhook_token?namespace=${namespace}&cluster_id=${cluster_id}&storage=${storage}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1047,4 +1062,5 @@ export default {
   updateCollaborator,
   removeCollaborator,
   getPolicyDocument,
+  createWebhookToken,
 };

+ 1 - 0
go.sum

@@ -1411,6 +1411,7 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

+ 6 - 2
internal/forms/user.go

@@ -19,6 +19,9 @@ type CreateUserForm struct {
 	WriteUserForm
 	Email    string `json:"email" form:"required,max=255,email"`
 	Password string `json:"password" form:"required,max=255"`
+
+	// ignore this field from the json
+	EmailVerified bool `json:"-"`
 }
 
 // ToUser converts a CreateUserForm to models.User
@@ -30,8 +33,9 @@ func (cuf *CreateUserForm) ToUser(_ repository.UserRepository) (*models.User, er
 	}
 
 	return &models.User{
-		Email:    cuf.Email,
-		Password: string(hashed),
+		Email:         cuf.Email,
+		Password:      string(hashed),
+		EmailVerified: cuf.EmailVerified,
 	}, nil
 }
 

+ 1 - 1
server/api/oauth_github_handler.go

@@ -222,7 +222,7 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 		if err == gorm.ErrRecordNotFound {
 			user = &models.User{
 				Email:         primary,
-				EmailVerified: verified,
+				EmailVerified: !app.Capabilities.Email || verified,
 				GithubUserID:  githubUser.GetID(),
 			}
 

+ 1 - 1
server/api/oauth_google_handler.go

@@ -146,7 +146,7 @@ func (app *App) upsertGoogleUserFromToken(tok *oauth2.Token) (*models.User, erro
 		if err == gorm.ErrRecordNotFound {
 			user = &models.User{
 				Email:         gInfo.Email,
-				EmailVerified: gInfo.EmailVerified,
+				EmailVerified: !app.Capabilities.Email || gInfo.EmailVerified,
 				GoogleUserID:  gInfo.Sub,
 			}
 

+ 92 - 1
server/api/release_handler.go

@@ -3,13 +3,14 @@ package api
 import (
 	"encoding/json"
 	"fmt"
-	"gorm.io/gorm"
 	"net/http"
 	"net/url"
 	"strconv"
 	"strings"
 	"sync"
 
+	"gorm.io/gorm"
+
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
@@ -782,6 +783,96 @@ func (app *App) HandleGetReleaseToken(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateWebhookToken creates a new webhook token for a release
+func (app *App) HandleCreateWebhookToken(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	// read the release from the target cluster
+	form := &forms.ReleaseForm{
+		Form: &helm.Form{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.Repo.Cluster,
+	)
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form,
+	)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	rel, err := agent.GetRelease(name, 0)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	token, err := repository.GenerateRandomBytes(16)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// create release with webhook token in db
+	image, ok := rel.Config["image"].(map[string]interface{})
+	if !ok {
+		app.handleErrorInternal(fmt.Errorf("Could not find field image in config"), w)
+		return
+	}
+
+	repository := image["repository"]
+	repoStr, ok := repository.(string)
+
+	if !ok {
+		app.handleErrorInternal(fmt.Errorf("Could not find field repository in config"), w)
+		return
+	}
+
+	release := &models.Release{
+		ClusterID:    form.Form.Cluster.ID,
+		ProjectID:    form.Form.Cluster.ProjectID,
+		Namespace:    form.Form.Namespace,
+		Name:         name,
+		WebhookToken: token,
+		ImageRepoURI: repoStr,
+	}
+
+	release, err = app.Repo.Release.CreateRelease(release)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	releaseExt := release.Externalize()
+
+	if err := json.NewEncoder(w).Encode(releaseExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 type ContainerEnvConfig struct {
 	Container struct {
 		Env struct {

+ 4 - 1
server/api/user_handler.go

@@ -39,7 +39,10 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 		app.handleErrorDataRead(err, w)
 	}
 
-	form := &forms.CreateUserForm{}
+	form := &forms.CreateUserForm{
+		// if app can send email verification, set the email verified to false
+		EmailVerified: !app.Capabilities.Email,
+	}
 
 	user, err := app.writeUser(
 		form,

+ 12 - 3
server/middleware/auth.go

@@ -5,15 +5,16 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
-	"github.com/google/go-github/github"
-	"github.com/porter-dev/porter/internal/oauth"
-	"golang.org/x/oauth2"
 	"io/ioutil"
 	"net/http"
 	"net/url"
 	"strconv"
 	"strings"
 
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/auth/token"
@@ -217,6 +218,14 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 			}
 		}
 
+		// read the user and make sure the email is verified
+		user, err := auth.repo.User.ReadUser(userID)
+
+		if err != nil || !user.EmailVerified {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
 		// get the project
 		proj, err := auth.repo.Project.ReadProject(uint(projID))
 

+ 14 - 0
server/router/router.go

@@ -1117,6 +1117,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/releases/{name}/webhook_token",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateWebhookToken, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/releases/{name}/{revision}",