Procházet zdrojové kódy

Merge branch 'master' into 0.5.0-forms-refactor

Ivan Galakhov před 4 roky
rodič
revize
cbe1055804
100 změnil soubory, kde provedl 4145 přidání a 1085 odebrání
  1. 8 3
      .github/workflows/dev.yaml
  2. 7 2
      .github/workflows/production.yaml
  3. 7 2
      .github/workflows/staging.yaml
  4. 101 0
      api/types/policy.go
  5. 4 0
      cli/cmd/docker.go
  6. 2 0
      cmd/app/main.go
  7. 8 6
      cmd/migrate/keyrotate/helpers_test.go
  8. 2 0
      cmd/migrate/main.go
  9. binární
      dashboard/src/assets/node.png
  10. 12 4
      dashboard/src/components/Loading.tsx
  11. 4 1
      dashboard/src/components/RadioSelector.tsx
  12. 22 8
      dashboard/src/components/SaveButton.tsx
  13. 93 0
      dashboard/src/components/SearchBar.tsx
  14. 86 0
      dashboard/src/components/StatusSection.tsx
  15. 20 6
      dashboard/src/components/Table.tsx
  16. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  17. 2 2
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  18. 71 50
      dashboard/src/components/repo-selector/BranchList.tsx
  19. 4 0
      dashboard/src/components/repo-selector/ContentsList.tsx
  20. 11 93
      dashboard/src/components/repo-selector/RepoList.tsx
  21. 22 2
      dashboard/src/components/values-form/FormWrapper.tsx
  22. 27 2
      dashboard/src/components/values-form/Heading.tsx
  23. 14 14
      dashboard/src/components/values-form/KeyValueArray.tsx
  24. 114 0
      dashboard/src/components/values-form/ServiceRow.tsx
  25. 27 9
      dashboard/src/components/values-form/ValuesForm.tsx
  26. 4 1
      dashboard/src/main/MainWrapper.tsx
  27. 75 33
      dashboard/src/main/home/Home.tsx
  28. 58 21
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  29. 11 3
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  30. 282 238
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  31. 17 2
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  32. 28 21
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  33. 35 23
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  34. 22 0
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  35. 18 20
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  36. 249 0
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx
  37. 125 0
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx
  38. 13 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  39. 2 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  40. 26 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  41. 57 22
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  42. 92 8
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  43. 27 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  44. 69 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  45. 10 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  46. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  47. 16 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  48. 28 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  49. 18 14
      dashboard/src/main/home/dashboard/Dashboard.tsx
  50. 1 1
      dashboard/src/main/home/integrations/IntegrationList.tsx
  51. 1 5
      dashboard/src/main/home/integrations/Integrations.tsx
  52. 2 2
      dashboard/src/main/home/launch/Launch.tsx
  53. 1 1
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  54. 0 20
      dashboard/src/main/home/launch/hardcodedNameDict.tsx
  55. 1 1
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  56. 12 3
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  57. 266 0
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  58. 1 1
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  59. 1 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  60. 176 0
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  61. 1 1
      dashboard/src/main/home/modals/EnvEditorModal.tsx
  62. 1 1
      dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx
  63. 1 1
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  64. 178 13
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  65. 1 1
      dashboard/src/main/home/modals/NamespaceModal.tsx
  66. 1 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  67. 46 7
      dashboard/src/main/home/navbar/Navbar.tsx
  68. 421 237
      dashboard/src/main/home/project-settings/InviteList.tsx
  69. 24 10
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  70. 34 22
      dashboard/src/main/home/sidebar/Sidebar.tsx
  71. 3 1
      dashboard/src/shared/Context.tsx
  72. 63 0
      dashboard/src/shared/api.tsx
  73. 51 0
      dashboard/src/shared/auth/AuthContext.tsx
  74. 54 0
      dashboard/src/shared/auth/AuthorizationHoc.tsx
  75. 62 0
      dashboard/src/shared/auth/RouteGuard.tsx
  76. 127 0
      dashboard/src/shared/auth/authorization-helpers.ts
  77. 28 0
      dashboard/src/shared/auth/types.ts
  78. 21 0
      dashboard/src/shared/auth/useAuth.ts
  79. 62 0
      dashboard/src/shared/hardcodedNameDict.tsx
  80. 1 0
      dashboard/src/shared/types.tsx
  81. 1 0
      dashboard/webpack.config.js
  82. 2 0
      docker-compose.dev.yaml
  83. 1 0
      docker/Dockerfile
  84. 31 0
      docs/guides/authorization-and-team-management.md
  85. 23 0
      docs/guides/linking-github-account.md
  86. 5 2
      internal/auth/sessionstore/sessionstore.go
  87. 14 4
      internal/config/config.go
  88. 8 6
      internal/forms/helper_test.go
  89. 2 0
      internal/forms/invite.go
  90. 3 16
      internal/forms/project.go
  91. 1 0
      internal/helm/agent.go
  92. 28 20
      internal/helm/postrenderer.go
  93. 218 3
      internal/kubernetes/agent.go
  94. 3 3
      internal/kubernetes/config.go
  95. 31 0
      internal/kubernetes/local/kubeconfig.go
  96. 9 6
      internal/kubernetes/nodes/helpers.go
  97. 37 6
      internal/kubernetes/nodes/nodes.go
  98. 11 24
      internal/kubernetes/provisioner/provisioner.go
  99. 33 0
      internal/models/integrations/github_app.go
  100. 21 7
      internal/models/integrations/oauth.go

+ 8 - 3
.github/workflows/dev.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:dev
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
-          kubectl rollout restart deployment/porter
+          kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/production.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:latest
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            production-2 --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
             
           kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/staging.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:staging
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
             
           kubectl rollout restart deployment/porter

+ 101 - 0
api/types/policy.go

@@ -0,0 +1,101 @@
+package types
+
+type PermissionScope string
+
+const (
+	UserScope        PermissionScope = "user"
+	ProjectScope     PermissionScope = "project"
+	ClusterScope     PermissionScope = "cluster"
+	NamespaceScope   PermissionScope = "namespace"
+	SettingsScope    PermissionScope = "settings"
+	ApplicationScope PermissionScope = "application"
+)
+
+type NameOrUInt struct {
+	Name string `json:"name"`
+	UInt uint   `json:"uint"`
+}
+
+type PolicyDocument struct {
+	Scope     PermissionScope                     `json:"scope"`
+	Resources []NameOrUInt                        `json:"resources"`
+	Verbs     []APIVerb                           `json:"verbs"`
+	Children  map[PermissionScope]*PolicyDocument `json:"children"`
+}
+
+type ScopeTree map[PermissionScope]ScopeTree
+
+/* ScopeHeirarchy describes the scope tree:
+			Project
+		   /	   \
+		Cluster   Settings
+		/
+	Namespace
+       |
+	 Release
+*/
+var ScopeHeirarchy = ScopeTree{
+	ProjectScope: {
+		ClusterScope: {
+			NamespaceScope: {
+				ApplicationScope: {},
+			},
+		},
+		SettingsScope: {},
+	},
+}
+
+type Policy []*PolicyDocument
+
+type APIVerb string
+
+const (
+	APIVerbGet    APIVerb = "get"
+	APIVerbCreate APIVerb = "create"
+	APIVerbList   APIVerb = "list"
+	APIVerbUpdate APIVerb = "update"
+	APIVerbDelete APIVerb = "delete"
+)
+
+type APIVerbGroup []APIVerb
+
+func ReadVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList}
+}
+
+func ReadWriteVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList, APIVerbCreate, APIVerbUpdate, APIVerbDelete}
+}
+
+var AdminPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+	},
+}
+
+var DeveloperPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: ReadVerbGroup(),
+			},
+		},
+	},
+}
+
+var ViewerPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: []APIVerb{},
+			},
+		},
+	},
+}

+ 4 - 0
cli/cmd/docker.go

@@ -138,6 +138,10 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		configFile.CredentialHelpers = make(map[string]string)
 	}
 
+	if configFile.AuthConfigs == nil {
+		configFile.AuthConfigs = make(map[string]types.AuthConfig)
+	}
+
 	for _, regURL := range regToAdd {
 		// if this is a dockerhub registry, see if an auth config has already been generated
 		// for index.docker.io

+ 2 - 0
cmd/app/main.go

@@ -72,6 +72,8 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

+ 8 - 6
cmd/migrate/keyrotate/helpers_test.go

@@ -253,12 +253,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 2 - 0
cmd/migrate/main.go

@@ -57,6 +57,8 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

binární
dashboard/src/assets/node.png


+ 12 - 4
dashboard/src/components/Loading.tsx

@@ -4,6 +4,8 @@ import loading from "assets/loading.gif";
 
 type PropsType = {
   offset?: string;
+  width?: string;
+  height?: string;
 };
 
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
       </StyledLoading>
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
 `;
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-top: ${(props: { offset?: string }) => props.offset};
+  margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;

+ 4 - 1
dashboard/src/components/RadioSelector.tsx

@@ -17,7 +17,10 @@ export default class RadioSelector extends Component<PropsType, StateType> {
           (option: { label: string; value: string }, i: number) => {
             let selected = option.value === this.props.selected;
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                 </Indicator>

+ 22 - 8
dashboard/src/components/SaveButton.tsx

@@ -22,25 +22,29 @@ export default class SaveButton extends Component<PropsType, StateType> {
       if (this.props.status === "successful") {
         return (
           <StatusWrapper successful={true}>
-            <i className="material-icons">done</i> Successfully updated
+            <i className="material-icons">done</i>
+            <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "loading") {
         return (
           <StatusWrapper successful={false}>
-            <LoadingGif src={loading} /> Updating . . .
+            <LoadingGif src={loading} />
+            <StatusTextWrapper>Updating . . .</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "error") {
         return (
           <StatusWrapper successful={false}>
-            <i className="material-icons">error_outline</i> Could not update
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
         );
       } else {
         return (
           <StatusWrapper successful={false}>
-            <i className="material-icons">error_outline</i> {this.props.status}
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
           </StatusWrapper>
         );
       }
@@ -54,7 +58,7 @@ export default class SaveButton extends Component<PropsType, StateType> {
   render() {
     return (
       <ButtonWrapper makeFlush={this.props.makeFlush}>
-        {this.renderStatus()}
+        <div>{this.renderStatus()}</div>
         <Button
           disabled={this.props.disabled}
           onClick={this.props.onClick}
@@ -74,6 +78,15 @@ const LoadingGif = styled.img`
   margin-bottom: 0px;
 `;
 
+const StatusTextWrapper = styled.p`
+  display: -webkit-box;
+  line-clamp: 2;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 19px;
+  margin: 0;
+`;
+
 const StatusWrapper = styled.div`
   display: flex;
   align-items: center;
@@ -81,16 +94,14 @@ const StatusWrapper = styled.div`
   font-size: 13px;
   color: #ffffff55;
   margin-right: 25px;
-  padding: 0 10px;
-
   max-width: 500px;
-  white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 
   > i {
     font-size: 18px;
     margin-right: 10px;
+    float: left;
     color: ${(props: { successful: boolean }) =>
       props.successful ? "#4797ff" : "#fcba03"};
   }
@@ -114,11 +125,13 @@ const ButtonWrapper = styled.div`
   display: flex;
   align-items: center;
   position: absolute;
+  justify-content: flex-end;
   ${(props: { makeFlush: boolean }) => {
     if (!props.makeFlush) {
       return `
         bottom: 25px;
         right: 27px;
+        left: 27px;
       `;
     }
     return `
@@ -134,6 +147,7 @@ const Button = styled.button`
   font-weight: 500;
   font-family: "Work Sans", sans-serif;
   color: white;
+  flex: 0 0 auto;
   padding: 6px 20px 7px 20px;
   text-align: left;
   border: 0;

+ 93 - 0
dashboard/src/components/SearchBar.tsx

@@ -0,0 +1,93 @@
+import React, { useState } from "react";
+import Button from "./Button";
+import styled from "styled-components";
+
+interface Props {
+  setSearchFilter: (x: string) => void;
+  disabled: boolean;
+  prompt?: string;
+}
+
+const SearchBar: React.FC<Props> = ({ setSearchFilter, disabled, prompt }) => {
+  const [searchInput, setSearchInput] = useState("");
+
+  return (
+    <SearchRowWrapper>
+      <SearchBarWrapper>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={searchInput}
+          onChange={(e: any) => {
+            setSearchInput(e.target.value);
+          }}
+          onKeyPress={({ key }) => {
+            if (key === "Enter") {
+              setSearchFilter(searchInput);
+            }
+          }}
+          placeholder={prompt}
+        />
+      </SearchBarWrapper>
+      <ButtonWrapper disabled={disabled}>
+        <Button
+          onClick={() => setSearchFilter(searchInput)}
+          disabled={disabled}
+        >
+          Search
+        </Button>
+      </ButtonWrapper>
+    </SearchRowWrapper>
+  );
+};
+
+export default SearchBar;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  background: #ffffff11;
+  border-bottom: 1px solid #606166;
+  margin-bottom: 10px;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-bottom: 0;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+`;
+
+const ButtonWrapper = styled.div`
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+  height: 40px;
+  display: flex;
+  align-items: center;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 13px;
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 20px;
+`;

+ 86 - 0
dashboard/src/components/StatusSection.tsx

@@ -0,0 +1,86 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+
+type PropsType = {
+  status: string;
+};
+
+type StateType = {};
+
+// TODO: replace StatusIndicator
+export default class StatusSection extends Component<PropsType, StateType> {
+  renderIndicator = (status: string) => {
+    if (status == "loading") {
+      return (
+        <div>
+          <Spinner src={loading} />
+        </div>
+      );
+    }
+
+    return (
+      <div>
+        <StatusColor status={status} />
+      </div>
+    );
+  };
+
+  render() {
+    return (
+      <Status>
+        {this.renderIndicator(this.props.status)}
+        {this.props.status}
+      </Status>
+    );
+  }
+}
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 15px;
+  margin-bottom: -3px;
+`;
+
+const StatusColor = styled.div`
+  margin-top: 1px;
+  max-width: 8px;
+  max-height: 8px;
+  min-width: 8px;
+  min-height: 8px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "deployed" || props.status === "healthy"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 4px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+const Status = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 20 - 6
dashboard/src/components/Table.tsx

@@ -31,6 +31,7 @@ export type TableProps = {
   onRowClick?: (row: Row) => void;
   isLoading: boolean;
   disableGlobalFilter?: boolean;
+  disableHover?: boolean;
 };
 
 const Table: React.FC<TableProps> = ({
@@ -39,6 +40,7 @@ const Table: React.FC<TableProps> = ({
   onRowClick,
   isLoading,
   disableGlobalFilter = false,
+  disableHover,
 }) => {
   const {
     getTableProps,
@@ -53,7 +55,7 @@ const Table: React.FC<TableProps> = ({
       columns: columnsData,
       data,
     },
-    useGlobalFilter
+    useGlobalFilter,
   );
 
   const renderRows = () => {
@@ -81,7 +83,9 @@ const Table: React.FC<TableProps> = ({
 
           return (
             <StyledTr
+              disableHover={disableHover}
               {...row.getRowProps()}
+              enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
             >
@@ -129,14 +133,21 @@ const TableWrapper = styled.div`
   padding-bottom: 20px;
 `;
 
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
 export const StyledTr = styled.tr`
   line-height: 2.2em;
-  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-    props.selected ? "#ffffff11" : ""};
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
   :hover {
-    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    background: ${(props: StyledTrProps) =>
       props.disableHover ? "" : "#ffffff22"};
   }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
 `;
 
 export const StyledTd = styled.td`
@@ -148,10 +159,13 @@ export const StyledTd = styled.td`
   :last-child {
     padding-right: 10px;
   }
+  user-select: text;
 `;
 
 export const StyledTHead = styled.thead`
   width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
 `;
 
 export const StyledTh = styled.th`
@@ -196,8 +210,8 @@ const SearchRow = styled.div`
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
-  margin-bottom: 7px;
-  margin-top: 7px;
+  margin-bottom: 15px;
+  margin-top: 0px;
   i {
     width: 18px;
     height: 18px;

+ 59 - 0
dashboard/src/components/UnauthorizedPage.tsx

@@ -0,0 +1,59 @@
+import React from "react";
+import styled from "styled-components";
+
+const UnauthorizedPage: React.FunctionComponent = () => (
+  <StyledUnauthorizedPage>
+    <Mega>
+      401
+      <Inside>You're not authorized to access this page</Inside>
+    </Mega>
+  </StyledUnauthorizedPage>
+);
+
+export default UnauthorizedPage;
+
+const StyledUnauthorizedPage = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  padding-bottom: 20px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 2 - 2
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -49,12 +49,12 @@ const ActionConfEditor: React.FC<Props> = (props) => {
   } else if (!branch) {
     return (
       <>
-        <ExpandedWrapper>
+        <ExpandedWrapperAlt>
           <BranchList
             actionConfig={actionConfig}
             setBranch={(branch: string) => setBranch(branch)}
           />
-        </ExpandedWrapper>
+        </ExpandedWrapperAlt>
         <Br />
         <BackButton
           width="135px"

+ 71 - 50
dashboard/src/components/repo-selector/BranchList.tsx

@@ -1,36 +1,28 @@
-import React, { Component } from "react";
+import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
 import branch_icon from "assets/branch.png";
-import info from "assets/info.svg";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
-import { ActionConfigType } from "../..//shared/types";
+import { ActionConfigType } from "../../shared/types";
 
 import Loading from "../Loading";
+import SearchBar from "../SearchBar";
 
-type PropsType = {
+type Props = {
   actionConfig: ActionConfigType;
   setBranch: (x: string) => void;
 };
 
-type StateType = {
-  loading: boolean;
-  error: boolean;
-  branches: string[];
-};
-
-export default class BranchList extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    error: false,
-    branches: [] as string[],
-  };
+const BranchList: React.FC<Props> = ({ setBranch, actionConfig }) => {
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState(false);
+  const [branches, setBranches] = useState<string[]>([]);
+  const [searchFilter, setSearchFilter] = useState(null);
 
-  componentDidMount() {
-    let { actionConfig } = this.props;
-    let { currentProject } = this.context;
+  const { currentProject } = useContext(Context);
 
+  useEffect(() => {
     // Get branches
     api
       .getBranches(
@@ -44,17 +36,19 @@ export default class BranchList extends Component<PropsType, StateType> {
           name: actionConfig.git_repo.split("/")[1],
         }
       )
-      .then((res) =>
-        this.setState({ branches: res.data, loading: false, error: false })
-      )
+      .then((res) => {
+        setBranches(res.data);
+        setLoading(false);
+        setError(false);
+      })
       .catch((err) => {
         console.log(err);
-        this.setState({ loading: false, error: true });
+        setLoading(false);
+        setError(true);
       });
-  }
+  }, []);
 
-  renderBranchList = () => {
-    let { branches, loading, error } = this.state;
+  const renderBranchList = () => {
     if (loading) {
       return (
         <LoadingWrapper>
@@ -65,34 +59,47 @@ export default class BranchList extends Component<PropsType, StateType> {
       return <LoadingWrapper>Error loading branches</LoadingWrapper>;
     }
 
-    return branches.map((branch: string, i: number) => {
+    let results =
+      searchFilter != null
+        ? branches.filter((branch) => {
+            return branch
+              .toLowerCase()
+              .includes(searchFilter.toLowerCase() || "");
+          })
+        : branches.slice(0, 10);
+
+    if (results.length == 0) {
+      return <LoadingWrapper>No matching Branches found.</LoadingWrapper>;
+    }
+    return results.map((branch: string, i: number) => {
       return (
         <BranchName
           key={i}
           lastItem={i === branches.length - 1}
-          onClick={() => this.props.setBranch(branch)}
+          onClick={() => setBranch(branch)}
         >
-          <img src={branch_icon} />
+          <img src={branch_icon} alt={"branch icon"} />
           {branch}
         </BranchName>
       );
     });
   };
 
-  render() {
-    return (
-      <>
-        <InfoRow lastItem={false}>
-          <img src={info} />
-          Select Branch
-        </InfoRow>
-        {this.renderBranchList()}
-      </>
-    );
-  }
-}
+  return (
+    <>
+      <SearchBar
+        setSearchFilter={setSearchFilter}
+        disabled={error || loading}
+        prompt={"Search branches..."}
+      />
+      <BranchListWrapper>
+        <ExpandedWrapper>{renderBranchList()}</ExpandedWrapper>
+      </BranchListWrapper>
+    </>
+  );
+};
 
-BranchList.contextType = Context;
+export default BranchList;
 
 const BranchName = styled.div`
   display: flex;
@@ -123,14 +130,6 @@ const BranchName = styled.div`
   }
 `;
 
-const InfoRow = styled(BranchName)`
-  cursor: default;
-  color: #ffffff55;
-  :hover {
-    background: #ffffff11;
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;
@@ -140,3 +139,25 @@ const LoadingWrapper = styled.div`
   font-size: 13px;
   color: #ffffff44;
 `;
+
+const BranchListWrapper = styled.div`
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  overflow-y: auto;
+`;
+
+const ExpandedWrapper = styled.div`
+  width: 100%;
+  border-radius: 3px;
+  border: 0px solid #ffffff44;
+  max-height: 221px;
+  top: 40px;
+
+  > i {
+    font-size: 18px;
+    display: block;
+    position: absolute;
+    left: 10px;
+    top: 10px;
+  }
+`;

+ 4 - 0
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -273,6 +273,10 @@ export default class ContentsList extends Component<PropsType, StateType> {
         );
       }
 
+      if (processes.length == 0) {
+        this.props.setProcfilePath("");
+      }
+
       return (
         <Overlay>
           <BgOverlay

+ 11 - 93
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useContext, useEffect, useRef } from "react";
+import React, { useState, useContext, useEffect } from "react";
 import styled from "styled-components";
 import github from "assets/github.png";
 
@@ -7,8 +7,7 @@ import { RepoType, ActionConfigType } from "shared/types";
 import { Context } from "shared/Context";
 
 import Loading from "../Loading";
-import Button from "../Button";
-import { AxiosResponse } from "axios";
+import SearchBar from "../SearchBar";
 
 type Props = {
   actionConfig: ActionConfigType | null;
@@ -27,7 +26,6 @@ const RepoList: React.FC<Props> = ({
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState(false);
   const [searchFilter, setSearchFilter] = useState(null);
-  const [searchInput, setSearchInput] = useState("");
   const { currentProject } = useContext(Context);
 
   // TODO: Try to unhook before unmount
@@ -135,7 +133,9 @@ const RepoList: React.FC<Props> = ({
     let results =
       searchFilter != null
         ? repos.filter((repo: RepoType) => {
-            return repo.FullName.includes(searchFilter || "");
+            return repo.FullName.toLowerCase().includes(
+              searchFilter.toLowerCase() || ""
+            );
           })
         : repos.slice(0, 10);
 
@@ -151,7 +151,7 @@ const RepoList: React.FC<Props> = ({
             onClick={() => setRepo(repo)}
             readOnly={readOnly}
           >
-            <img src={github} />
+            <img src={github} alt={"github icon"} />
             {repo.FullName}
           </RepoName>
         );
@@ -165,31 +165,11 @@ const RepoList: React.FC<Props> = ({
     } else {
       return (
         <>
-          <SearchRowTop>
-            <SearchBar>
-              <i className="material-icons">search</i>
-              <SearchInput
-                value={searchInput}
-                onChange={(e: any) => {
-                  setSearchInput(e.target.value);
-                }}
-                onKeyPress={({ key }) => {
-                  if (key === "Enter") {
-                    setSearchFilter(searchInput);
-                  }
-                }}
-                placeholder="Search repos..."
-              />
-            </SearchBar>
-            <ButtonWrapper disabled={loading || error}>
-              <Button
-                onClick={() => setSearchFilter(searchInput)}
-                disabled={loading || error}
-              >
-                Search
-              </Button>
-            </ButtonWrapper>
-          </SearchRowTop>
+          <SearchBar
+            setSearchFilter={setSearchFilter}
+            disabled={error || loading}
+            prompt={"Search repos..."}
+          />
           <RepoListWrapper>
             <ExpandedWrapper>{renderRepoList()}</ExpandedWrapper>
           </RepoListWrapper>
@@ -203,39 +183,12 @@ const RepoList: React.FC<Props> = ({
 
 export default RepoList;
 
-const ButtonWrapper = styled.div`
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-  height: 40px;
-  display: flex;
-  align-items: center;
-`;
-
 const RepoListWrapper = styled.div`
   border: 1px solid #ffffff55;
   border-radius: 3px;
   overflow-y: auto;
 `;
 
-const SearchRow = styled.div`
-  display: flex;
-  align-items: center;
-  height: 40px;
-  background: #ffffff11;
-  border-bottom: 1px solid #606166;
-  margin-bottom: 10px;
-`;
-
-const SearchRowTop = styled(SearchRow)`
-  border-bottom: 0;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-`;
-
 const RepoName = styled.div`
   display: flex;
   width: 100%;
@@ -280,18 +233,6 @@ const RepoName = styled.div`
   }
 `;
 
-const InfoRow = styled(RepoName)`
-  cursor: default;
-  color: #ffffff55;
-  :hover {
-    background: #ffffff11;
-
-    > i {
-      background: none;
-    }
-  }
-`;
-
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;
@@ -330,26 +271,3 @@ const A = styled.a`
   margin-left: 5px;
   cursor: pointer;
 `;
-
-const SearchBar = styled.div`
-  display: flex;
-  flex: 1;
-
-  > i {
-    color: #aaaabb;
-    padding-top: 1px;
-    margin-left: 13px;
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const SearchInput = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 100%;
-  color: white;
-  height: 20px;
-`;

+ 22 - 2
dashboard/src/components/values-form/FormWrapper.tsx

@@ -78,7 +78,10 @@ export default class FormWrapper extends Component<PropsType, StateType> {
       };
       if (tabs) {
         tabs.forEach((tab: any, i: number) => {
-          if (tab?.name && tab.label) {
+          // Exclude value if omitFromLaunch is set
+          let omit =
+            tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
+          if (tab?.name && tab.label && !omit) {
             // If a tab is valid, extract state
             tab.sections?.forEach((section: Section, i: number) => {
               section?.contents?.forEach((item: FormElement, i: number) => {
@@ -198,7 +201,18 @@ export default class FormWrapper extends Component<PropsType, StateType> {
         });
       }
       if (this.props.tabOptions?.length > 0) {
-        tabOptions = tabOptions.concat(this.props.tabOptions);
+        let prependTabs = [] as { value: string; label: string }[];
+        let appendTabs = [] as { value: string; label: string }[];
+        this.props.tabOptions.forEach(
+          (tab: { value: string; label: string }) => {
+            if (tab.value === "status" || tab.value === "metrics") {
+              prependTabs.push(tab);
+            } else {
+              appendTabs.push(tab);
+            }
+          }
+        );
+        tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
       }
       this.setState({ tabOptions }, callback);
     }
@@ -255,6 +269,12 @@ export default class FormWrapper extends Component<PropsType, StateType> {
       !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
       !_.isEqual(prevProps.formData, this.props.formData)
     ) {
+      if (
+        prevProps.tabOptions?.length === 0 &&
+        !_.isEqual(prevProps.tabOptions, this.props.tabOptions)
+      ) {
+        this.setState({ currentTab: "status" });
+      }
       let formHasChanged = !_.isEqual(prevProps.formData, this.props.formData);
       this.updateTabs(formHasChanged);
     }

+ 27 - 2
dashboard/src/components/values-form/Heading.tsx

@@ -1,9 +1,20 @@
 import React from "react";
 import styled from "styled-components";
 
-export default function Heading(props: { isAtTop?: boolean; children: any }) {
+export default function Heading(props: {
+  isAtTop?: boolean;
+  children: any;
+  docs?: string;
+}) {
   return (
-    <StyledHeading isAtTop={props.isAtTop}>{props.children}</StyledHeading>
+    <StyledHeading isAtTop={props.isAtTop}>
+      {props.children}
+      {props.docs && (
+        <a href={props.docs} target="_blank">
+          <i className="material-icons">help_outline</i>
+        </a>
+      )}
+    </StyledHeading>
   );
 }
 
@@ -15,4 +26,18 @@ const StyledHeading = styled.div<{ isAtTop: boolean }>`
   margin-bottom: 5px;
   display: flex;
   align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 16px;
+      margin-left: 12px;
+      color: #858faaaa;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
 `;

+ 14 - 14
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -8,6 +8,11 @@ import sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
 import { keysIn } from "lodash";
 
+export type KeyValue = {
+  key: string;
+  value: string;
+};
+
 type PropsType = {
   label?: string;
   values: any;
@@ -51,15 +56,8 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     return obj;
   };
 
-  objectToValues = (obj: any) => {
-    let values = [] as any[];
-    Object.keys(obj).forEach((key: string, i: number) => {
-      let entry = {} as any;
-      entry.key = key;
-      entry.value = obj[key];
-      values.push(entry);
-    });
-    return values;
+  objectToValues = (obj: Record<string, string>): KeyValue[] => {
+    return Object.entries(obj).map(([key, value]) => ({ key, value }));
   };
 
   renderDeleteButton = (i: number) => {
@@ -148,16 +146,18 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       return (
         <Modal
           onRequestClose={() => this.setState({ showEnvModal: false })}
-          width="665px"
-          height="342px"
+          width="765px"
+          height="542px"
         >
           <LoadEnvGroupModal
+            existingValues={this.props.values}
             namespace={this.props.externalValues?.namespace}
             clusterId={this.props.externalValues?.clusterId}
             closeModal={() => this.setState({ showEnvModal: false })}
-            setValues={(values: any) => {
-              this.props.setValues(values);
-              this.setState({ values: this.objectToValues(values) });
+            setValues={(values) => {
+              const newValues = { ...this.props.values, ...values };
+              this.props.setValues(newValues);
+              this.setState({ values: this.objectToValues(newValues) });
             }}
           />
         </Modal>

+ 114 - 0
dashboard/src/components/values-form/ServiceRow.tsx

@@ -0,0 +1,114 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { hardcodedNames, hardcodedIcons } from "shared/hardcodedNameDict";
+
+type PropsType = {
+  service: {
+    clusterIP: string;
+    name: string;
+    release: string;
+    app: string;
+    namespace: string;
+    type?: string;
+  };
+};
+
+type StateType = any;
+
+export default class ServiceRow extends Component<PropsType, StateType> {
+  render() {
+    let { clusterIP, name, namespace, type, app, release } = this.props.service;
+    name = name || release;
+    type = type || app;
+    return (
+      <>
+        {name &&
+          type &&
+          hardcodedNames[type] &&
+          hardcodedIcons[type] &&
+          namespace !== "kube-system" && (
+            <StyledServiceRow>
+              <Flex>
+                <Icon src={hardcodedIcons[type]} />
+                <Type>{hardcodedNames[type]}</Type>
+                <Name>{name}</Name> <Dash>-</Dash> <IP>{clusterIP}</IP>
+              </Flex>
+              <TagWrapper>
+                Namespace: <NamespaceTag>{namespace}</NamespaceTag>
+              </TagWrapper>
+            </StyledServiceRow>
+          )}
+      </>
+    );
+  }
+}
+
+ServiceRow.contextType = Context;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const TagWrapper = styled.div`
+  float: right;
+  height: 20px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border-right: 0;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  border-radius: 3px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-left: 3px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Dash = styled.div`
+  margin-right: 10px;
+`;
+
+const Icon = styled.img`
+  width: 20px;
+  margin-right: 12px;
+`;
+
+const Type = styled.div`
+  color: #aaaabb;
+  margin-right: 15px;
+`;
+
+const Name = styled.div`
+  margin-right: 10px;
+`;
+
+const IP = styled.div`
+  user-select: text;
+  font-weight: 500;
+`;
+
+const StyledServiceRow = styled.div`
+  width: 100%;
+  height: 40px;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  border-radius: 5px;
+  padding: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;

+ 27 - 9
dashboard/src/components/values-form/ValuesForm.tsx

@@ -18,6 +18,7 @@ import SelectRow from "./SelectRow";
 import Helper from "./Helper";
 import Heading from "./Heading";
 import ExpandableResource from "../ExpandableResource";
+import ServiceRow from "./ServiceRow";
 import VeleroForm from "../forms/VeleroForm";
 import InputArray from "./InputArray";
 import KeyValueArray from "./KeyValueArray";
@@ -68,22 +69,38 @@ export default class ValuesForm extends Component<PropsType, StateType> {
 
       switch (item.type) {
         case "heading":
-          return <Heading key={i}>{item.label}</Heading>;
+          return (
+            <Heading key={i} docs={item.settings?.docs}>
+              {item.label}
+            </Heading>
+          );
         case "subtitle":
           return <Helper key={i}>{item.label}</Helper>;
+        case "service-ip-list":
+          if (Array.isArray(item.value)) {
+            return (
+              <ResourceList key={key}>
+                {item.value?.map((service: any, i: number) => {
+                  return <ServiceRow service={service} key={i} />;
+                })}
+              </ResourceList>
+            );
+          }
         case "resource-list":
           if (Array.isArray(item.value)) {
             return (
               <ResourceList key={key}>
                 {item.value?.map((resource: any, i: number) => {
-                  return (
-                    <ExpandableResource
-                      key={i}
-                      resource={resource}
-                      isLast={i === item.value.length - 1}
-                      roundAllCorners={true}
-                    />
-                  );
+                  if (resource.data) {
+                    return (
+                      <ExpandableResource
+                        key={i}
+                        resource={resource}
+                        isLast={i === item.value.length - 1}
+                        roundAllCorners={true}
+                      />
+                    );
+                  }
                 })}
               </ResourceList>
             );
@@ -181,6 +198,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <InputRow
               key={key}
               width="100%"
+              placeholder={item.placeholder}
               isRequired={item.required}
               type="password"
               value={this.getInputValue(item)}

+ 4 - 1
dashboard/src/main/MainWrapper.tsx

@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
 import { ContextProvider } from "../shared/Context";
 import Main from "./Main";
 import { RouteComponentProps, withRouter } from "react-router";
+import AuthProvider from "shared/auth/AuthContext";
 
 type PropsType = RouteComponentProps & {};
 
@@ -14,7 +15,9 @@ class MainWrapper extends Component<PropsType, StateType> {
     let { history, location } = this.props;
     return (
       <ContextProvider history={history} location={location}>
-        <Main />
+        <AuthProvider>
+          <Main />
+        </AuthProvider>
       </ContextProvider>
     );
   }

+ 75 - 33
dashboard/src/main/home/Home.tsx

@@ -26,13 +26,34 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
-
-type PropsType = RouteComponentProps & {
-  logOut: () => void;
-  currentProject: ProjectType;
-  currentCluster: ClusterType;
-  currentRoute: PorterUrl;
-};
+import { fakeGuardedRoute } from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
+import AccountSettingsModal from "./modals/AccountSettingsModal";
+// Guarded components
+const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
+  "get",
+  "list",
+  "update",
+  "create",
+  "delete",
+])(ProjectSettings);
+
+const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
+  "get",
+  "list",
+  "update",
+  "create",
+  "delete",
+])(Integrations);
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    logOut: () => void;
+    currentProject: ProjectType;
+    currentCluster: ClusterType;
+    currentRoute: PorterUrl;
+  };
 
 type StateType = {
   forceSidebar: boolean;
@@ -90,9 +111,6 @@ class Home extends Component<PropsType, StateType> {
   };
 
   getCapabilities = () => {
-    let { currentProject } = this.props;
-    if (!currentProject) return;
-
     api
       .getCapabilities("<token>", {}, {})
       .then((res) => {
@@ -336,9 +354,9 @@ class Home extends Component<PropsType, StateType> {
           </DashboardWrapper>
         );
       } else if (currentView === "integrations") {
-        return <Integrations />;
+        return <GuardedIntegrations />;
       } else if (currentView === "project-settings") {
-        return <ProjectSettings />;
+        return <GuardedProjectSettings />;
       }
       return <Templates />;
     } else if (currentView === "new-project") {
@@ -471,19 +489,22 @@ class Home extends Component<PropsType, StateType> {
             <ClusterInstructionsModal />
           </Modal>
         )}
-        {currentModal === "UpdateClusterModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="565px"
-            height="275px"
-          >
-            <UpdateClusterModal
-              setRefreshClusters={(x: boolean) =>
-                this.setState({ forceRefreshClusters: x })
-              }
-            />
-          </Modal>
-        )}
+
+        {/* We should be careful, as this component is named Update but is for deletion */}
+        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
+          currentModal === "UpdateClusterModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="565px"
+              height="275px"
+            >
+              <UpdateClusterModal
+                setRefreshClusters={(x: boolean) =>
+                  this.setState({ forceRefreshClusters: x })
+                }
+              />
+            </Modal>
+          )}
         {currentModal === "IntegrationsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
@@ -502,22 +523,43 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
-        {currentModal === "NamespaceModal" && (
+        {this.props.isAuthorized("namespace", "", ["get", "create"]) &&
+          currentModal === "NamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="600px"
+              height="220px"
+            >
+              <NamespaceModal />
+            </Modal>
+          )}
+        {this.props.isAuthorized("namespace", "", ["get", "delete"]) &&
+          currentModal === "DeleteNamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="700px"
+              height="280px"
+            >
+              <DeleteNamespaceModal />
+            </Modal>
+          )}
+
+        {currentModal === "EditInviteOrCollaboratorModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             width="600px"
-            height="220px"
+            height="250px"
           >
-            <NamespaceModal />
+            <EditInviteOrCollaboratorModal />
           </Modal>
         )}
-        {currentModal === "DeleteNamespaceModal" && (
+        {currentModal === "AccountSettingsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
-            width="700px"
-            height="280px"
+            width="760px"
+            height="440px"
           >
-            <DeleteNamespaceModal />
+            <AccountSettingsModal />
           </Modal>
         )}
 
@@ -548,7 +590,7 @@ class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-export default withRouter(Home);
+export default withRouter(withAuth(Home));
 
 const ViewWrapper = styled.div`
   height: 100%;

+ 58 - 21
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -17,18 +17,20 @@ import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
-import { Dashboard } from "./dashboard/Dashboard";
-
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-  setSidebar: (x: boolean) => void;
-  currentView: PorterUrl;
-};
+import DashboardRoutes from "./dashboard/Routes";
+import GuardedRoute from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+    setSidebar: (x: boolean) => void;
+    currentView: PorterUrl;
+  };
 
 type StateType = {
   namespace: string;
@@ -128,14 +130,23 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
   renderBody = () => {
     let { currentCluster, currentView } = this.props;
+    const isAuthorizedToAdd = this.props.isAuthorized(
+      "namespace",
+      [],
+      ["get", "create"]
+    );
     return (
       <>
-        <ControlRow>
-          <Button
-            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
-          >
-            <i className="material-icons">add</i> Launch Template
-          </Button>
+        <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+          {isAuthorizedToAdd && (
+            <Button
+              onClick={() =>
+                pushFiltered(this.props, "/launch", ["project_id"])
+              }
+            >
+              <i className="material-icons">add</i> Launch Template
+            </Button>
+          )}
           <SortFilterWrapper>
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
@@ -203,11 +214,32 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             isMetricsInstalled={this.state.isMetricsInstalled}
           />
         </Route>
-        <Route path={["/jobs", "/applications", "/env-groups"]}>
+        <GuardedRoute
+          path={"/jobs"}
+          scope="job"
+          resource=""
+          verb={["get", "list"]}
+        >
           {this.renderContents()}
-        </Route>
+        </GuardedRoute>
+        <GuardedRoute
+          path={"/applications"}
+          scope="application"
+          resource=""
+          verb={["get", "list"]}
+        >
+          {this.renderContents()}
+        </GuardedRoute>
+        <GuardedRoute
+          path={"/env-groups"}
+          scope="env_group"
+          resource=""
+          verb={["get", "list"]}
+        >
+          {this.renderContents()}
+        </GuardedRoute>
         <Route path={["/cluster-dashboard"]}>
-          <Dashboard />
+          <DashboardRoutes />
         </Route>
       </Switch>
     );
@@ -216,11 +248,16 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
 ClusterDashboard.contextType = Context;
 
-export default withRouter(ClusterDashboard);
+export default withRouter(withAuth(ClusterDashboard));
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;
@@ -388,8 +425,8 @@ const TitleSection = styled.div`
   > i {
     margin-left: 10px;
     cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
+    font-size: 18px;
+    color: #858faaaa;
     padding: 5px;
     border-radius: 100px;
     :hover {

+ 11 - 3
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -11,9 +11,14 @@ import api from "shared/api";
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
+  release: any;
 };
 
-const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
+const Chart: React.FunctionComponent<Props> = ({
+  chart,
+  controllers,
+  release,
+}) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
   const context = useContext(Context);
@@ -105,7 +110,10 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
             margin_left={"17px"}
           />
           <LastDeployed>
-            <Dot>•</Dot> Last deployed {readableDate(chart.info.last_deployed)}
+            <Dot>•</Dot> Last deployed{" "}
+            {readableDate(
+              release?.info?.last_deployed || chart.info.last_deployed
+            )}
           </LastDeployed>
         </InfoWrapper>
 
@@ -115,7 +123,7 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
         </TagWrapper>
       </BottomWrapper>
 
-      <Version>v{chart.version}</Version>
+      <Version>v{release?.version || chart.version}</Version>
     </StyledChart>
   );
 };

+ 282 - 238
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -1,238 +1,282 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ChartType, StorageType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
-
-import Chart from "./Chart";
-import Loading from "components/Loading";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-type Props = {
-  currentCluster: ClusterType;
-  namespace: string;
-  // TODO Convert to enum
-  sortType: string;
-  currentView: PorterUrl;
-};
-
-const ChartList: React.FunctionComponent<Props> = ({
-  namespace,
-  sortType,
-  currentView,
-}) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-  const [charts, setCharts] = useState<ChartType[]>([]);
-  const [controllers, setControllers] = useState<
-    Record<string, Record<string, any>>
-  >({});
-  const [isLoading, setIsLoading] = useState(false);
-  const [isError, setIsError] = useState(false);
-
-  const context = useContext(Context);
-
-  const updateCharts = async () => {
-    try {
-      const { currentCluster, currentProject } = context;
-      setIsLoading(true);
-      const res = await api.getCharts(
-        "<token>",
-        {
-          namespace: namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-          limit: 50,
-          skip: 0,
-          byDate: false,
-          statusFilter: [
-            "deployed",
-            "uninstalled",
-            "pending",
-            "pending-install",
-            "pending-upgrade",
-            "pending-rollback",
-            "superseded",
-            "failed",
-          ],
-        },
-        { id: currentProject.id }
-      );
-      const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
-      setIsError(false);
-      return sortedCharts;
-    } catch (error) {
-      console.log(error);
-      context.setCurrentError(JSON.stringify(error));
-      setIsError(true);
-    }
-  };
-
-  const setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = context;
-    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
-
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [object.metadata.uid]: object,
-        }));
-      },
-      onclose: () => {
-        console.log("closing websocket");
-      },
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(kind);
-      },
-    };
-
-    newWebsocket(kind, apiPath, wsConfig);
-
-    openWebsocket(kind);
-  };
-
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
-  };
-
-  // Setup basic websockets on start
-  useEffect(() => {
-    setControllerWebsockets([
-      "deployment",
-      "statefulset",
-      "daemonset",
-      "replicaset",
-    ]);
-
-    return () => {
-      closeAllWebsockets();
-    };
-  }, []);
-
-  useEffect(() => {
-    let isSubscribed = true;
-
-    if (namespace || namespace === "") {
-      updateCharts().then((charts) => {
-        if (isSubscribed) {
-          setCharts(charts);
-          setIsLoading(false);
-        }
-      });
-    }
-    return () => (isSubscribed = false);
-  }, [namespace, currentView]);
-
-  const renderChartList = () => {
-    if (isLoading || (!namespace && namespace !== "")) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (isError) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error connecting to cluster.
-        </Placeholder>
-      );
-    } else if (charts.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
-        </Placeholder>
-      );
-    }
-
-    return charts.map((chart: ChartType, i: number) => {
-      return (
-        <Chart
-          key={`${chart.namespace}-${chart.name}`}
-          chart={chart}
-          controllers={controllers || {}}
-        />
-      );
-    });
-  };
-
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
-};
-
-export default ChartList;
-
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 320px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
-const LoadingWrapper = styled.div`
-  padding-top: 100px;
-`;
-
-const StyledChartList = styled.div`
-  padding-bottom: 85px;
-`;
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
+
+import Chart from "./Chart";
+import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  currentCluster: ClusterType;
+  namespace: string;
+  // TODO Convert to enum
+  sortType: string;
+  currentView: PorterUrl;
+};
+
+const ChartList: React.FunctionComponent<Props> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          namespace: namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "superseded",
+            "failed",
+          ],
+        },
+        { id: currentProject.id }
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    }
+  };
+
+  const setupHelmReleasesWebsocket = () => {
+    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to chart live updates websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        const object = event.Object;
+        setReleases((oldReleases) => {
+          const currentRelease = oldReleases[object?.name];
+          const currentReleaseVersion = Number(currentRelease?.version);
+          const newReleaseVersion = Number(object?.version);
+          if (currentReleaseVersion > newReleaseVersion) {
+            return {
+              ...oldReleases,
+            };
+          }
+
+          return {
+            ...oldReleases,
+            [object.name]: object,
+          };
+        });
+      },
+
+      onclose: () => {
+        console.log("closing chart live updates websocket");
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket("helm_releases");
+      },
+    };
+
+    newWebsocket("helm_releases", apiPath, wsConfig);
+    openWebsocket("helm_releases");
+  };
+
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiPath, wsConfig);
+
+    openWebsocket(kind);
+  };
+
+  const setControllerWebsockets = (controllers: any[]) => {
+    controllers.map((kind: string) => {
+      return setupWebsocket(kind);
+    });
+  };
+
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
+      "deployment",
+      "statefulset",
+      "daemonset",
+      "replicaset",
+    ]);
+    setupHelmReleasesWebsocket();
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          setIsLoading(false);
+        }
+      });
+    }
+    return () => (isSubscribed = false);
+  }, [namespace, currentView]);
+
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (isError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (charts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
+        </Placeholder>
+      );
+    }
+
+    return charts.map((chart: ChartType, i: number) => {
+      return (
+        <Chart
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
+          controllers={controllers || {}}
+          release={releases[chart.name] || {}}
+        />
+      );
+    });
+  };
+
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
+
+export default ChartList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 85px;
+`;

+ 17 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -8,6 +8,7 @@ import NodeList from "./NodeList";
 
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
+import useAuth from "shared/auth/useAuth";
 
 type TabEnum = "nodes" | "settings" | "namespaces";
 
@@ -22,6 +23,9 @@ const tabOptions: {
 
 export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
+  const [isAuthorized] = useAuth();
+
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
@@ -35,6 +39,17 @@ export const Dashboard: React.FunctionComponent = () => {
     }
   };
 
+  useEffect(() => {
+    setCurrentTabOptions(
+      tabOptions.filter((option) => {
+        if (option.value === "settings") {
+          return isAuthorized("cluster", "", ["get", "delete"]);
+        }
+        return true;
+      })
+    );
+  }, [isAuthorized]);
+
   return (
     <>
       <TitleSection>
@@ -56,7 +71,7 @@ export const Dashboard: React.FunctionComponent = () => {
       </InfoSection>
 
       <TabSelector
-        options={tabOptions}
+        options={currentTabOptions}
         currentTab={currentTab}
         setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
       />

+ 28 - 21
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -4,6 +4,7 @@ import { Context } from "shared/Context";
 import { ClusterType, ProjectType } from "shared/types";
 import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
+import useAuth from "shared/auth/useAuth";
 
 const OptionsDropdown: React.FC = ({ children }) => {
   const [isOpen, setIsOpen] = useState(false);
@@ -68,6 +69,8 @@ export const NamespaceList: React.FunctionComponent = () => {
     setCurrentModal("DeleteNamespaceModal", namespace);
   };
 
+  const [isAuthorized] = useAuth();
+
   const isAvailableForDeletion = (namespaceName: string) => {
     // Only the namespaces that doesn't start with kube- or has by name default will be
     // available for deletion (as those are the k8s namespaces)
@@ -109,7 +112,7 @@ export const NamespaceList: React.FunctionComponent = () => {
             (namespace) => namespace.metadata.name === data.Object.metadata.name
           );
           oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
-          return oldNamespaces;
+          return [...oldNamespaces];
         });
       }
     };
@@ -133,18 +136,20 @@ export const NamespaceList: React.FunctionComponent = () => {
   return (
     <NamespaceListWrapper>
       <ControlRow>
-        <Button
-          onClick={() =>
-            setCurrentModal(
-              "NamespaceModal",
-              namespaces.map((namespace) => ({
-                value: namespace.metadata.name,
-              }))
-            )
-          }
-        >
-          <i className="material-icons">add</i> Add namespace
-        </Button>
+        {isAuthorized("namespace", "", ["get", "create"]) && (
+          <Button
+            onClick={() =>
+              setCurrentModal(
+                "NamespaceModal",
+                namespaces.map((namespace) => ({
+                  value: namespace.metadata.name,
+                }))
+              )
+            }
+          >
+            <i className="material-icons">add</i> Add namespace
+          </Button>
+        )}
       </ControlRow>
       <NamespacesGrid>
         {sortedNamespaces.map((namespace) => {
@@ -165,14 +170,16 @@ export const NamespaceList: React.FunctionComponent = () => {
                   {namespace?.status?.phase}
                 </Status>
               </ContentContainer>
-              {isAvailableForDeletion(namespace?.metadata?.name) && (
-                <OptionsDropdown>
-                  <DropdownOption onClick={() => onDelete(namespace)}>
-                    <i className="material-icons-outlined">delete</i>
-                    <span>Delete</span>
-                  </DropdownOption>
-                </OptionsDropdown>
-              )}
+              {isAuthorized("namespace", "", ["get", "delete"]) &&
+                isAvailableForDeletion(namespace?.metadata?.name) &&
+                namespace?.status?.phase === "Active" && (
+                  <OptionsDropdown>
+                    <DropdownOption onClick={() => onDelete(namespace)}>
+                      <i className="material-icons-outlined">delete</i>
+                      <span>Delete</span>
+                    </DropdownOption>
+                  </OptionsDropdown>
+                )}
             </StyledCard>
           );
         })}

+ 35 - 23
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -1,33 +1,30 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
 import Table from "components/Table";
-import { Column } from "react-table";
+import { Column, Row } from "react-table";
 import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { NodeStatusModal } from "./NodeStatusModal";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
 
 const NodeList: React.FC = () => {
   const context = useContext(Context);
   const [nodeList, setNodeList] = useState([]);
   const [loading, setLoading] = useState<boolean>(false);
-  const [selectedNode, setSelectedNode] = useState<any>(undefined);
-
-  const triggerPopUp = (node?: any) => {
-    if (node) {
-      setSelectedNode(node);
-      return;
-    }
-
-    setSelectedNode(undefined);
-  };
+  const history = useHistory();
+  const location = useLocation();
 
   const columns = useMemo<Column<any>[]>(
     () => [
       {
-        Header: "Node name",
+        Header: "Node Name",
         accessor: "name",
       },
+      {
+        Header: "Machine Type",
+        accessor: "machine_type",
+      },
       {
         Header: "CPU Usage",
         accessor: "cpu_usage",
@@ -42,10 +39,7 @@ const NodeList: React.FC = () => {
         Cell: ({ row }) => {
           return (
             <StatusButtonWrapper>
-              <StatusButton
-                success={row.values.is_node_healthy}
-                onClick={() => triggerPopUp(row.original)}
-              >
+              <StatusButton success={row.values.is_node_healthy}>
                 {row.values.is_node_healthy ? "Healthy" : "Unhealthy"}
               </StatusButton>
             </StatusButtonWrapper>
@@ -60,12 +54,17 @@ const NodeList: React.FC = () => {
     const percentFormatter = (number: number) =>
       `${Number(number).toFixed(2)}%`;
 
+    const getMachineType = (labels: any) => {
+      return (labels && labels["node.kubernetes.io/instance-type"]) || "N/A";
+    };
+
     return nodeList
       .map((node) => {
         return {
           name: node.name,
-          cpu_usage: percentFormatter(node.cpu_reqs),
-          ram_usage: percentFormatter(node.memory_reqs),
+          machine_type: getMachineType(node?.labels),
+          cpu_usage: percentFormatter(node.fraction_cpu_reqs),
+          ram_usage: percentFormatter(node.fraction_memory_reqs),
           node_conditions: node.node_conditions,
           is_node_healthy: node.node_conditions.reduce(
             (prevValue: boolean, current: any) => {
@@ -113,14 +112,27 @@ const NodeList: React.FC = () => {
       .finally(() => setLoading(false));
   }, [context, setNodeList]);
 
+  const handleOnRowClick = (row: any) => {
+    pushFiltered(
+      {
+        history,
+        location,
+      },
+      `/cluster-dashboard/node-view/${row.original.name}`,
+      []
+    );
+  };
+
   return (
     <NodeListWrapper>
       <StyledChart>
-        <Table columns={columns} data={data} isLoading={loading} />
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={loading}
+          onRowClick={handleOnRowClick}
+        />
       </StyledChart>
-      {selectedNode && (
-        <NodeStatusModal node={selectedNode} onClose={() => triggerPopUp()} />
-      )}
     </NodeListWrapper>
   );
 };

+ 22 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -0,0 +1,22 @@
+import React from "react";
+import { Route, Switch, useRouteMatch } from "react-router";
+import { Dashboard } from "./Dashboard";
+import ExpandedNodeView from "./node-view/ExpandedNodeView";
+
+export const Routes = () => {
+  const { url } = useRouteMatch();
+  return (
+    <>
+      <Switch>
+        <Route path={`${url}/node-view/:nodeId`}>
+          <ExpandedNodeView />
+        </Route>
+        <Route path={`${url}/`}>
+          <Dashboard />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default Routes;

+ 18 - 20
dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx → dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -1,21 +1,14 @@
 import React, { useMemo } from "react";
-import Modal from "../../modals/Modal";
 import Table from "components/Table";
 import { Column } from "react-table";
 import styled from "styled-components";
 
 type NodeStatusModalProps = {
-  onClose: () => void;
   node: any;
-  width?: string;
-  height?: string;
 };
 
-export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
-  onClose,
+export const ConditionsTable: React.FunctionComponent<NodeStatusModalProps> = ({
   node,
-  width = "800px",
-  height = "min-content",
 }) => {
   const columns = useMemo<Column<any>[]>(
     () => [
@@ -35,27 +28,32 @@ export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
         Header: "Message",
         accessor: "message",
       },
+      {
+        Header: "Last Transition",
+        accessor: "lastTransitionTime",
+        Cell: ({ row }) => {
+          const date = new Date(row.values.lastTransitionTime);
+          return <>{date.toLocaleString()}</>;
+        },
+      },
     ],
     []
   );
 
-  const data = useMemo(() => {
+  const data = useMemo<Array<any>>(() => {
     return node?.node_conditions || [];
   }, [node]);
 
   return (
     <div>
-      <Modal onRequestClose={onClose} width={width} height={height}>
-        Node {node?.name} conditions:
-        <TableWrapper>
-          <Table
-            columns={columns}
-            data={data}
-            isLoading={false}
-            disableGlobalFilter={true}
-          />
-        </TableWrapper>
-      </Modal>
+      <TableWrapper>
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={!data.length}
+          disableGlobalFilter={true}
+        />
+      </TableWrapper>
     </div>
   );
 };

+ 249 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx

@@ -0,0 +1,249 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useHistory, useLocation, useParams } from "react-router";
+import styled from "styled-components";
+import closeImg from "assets/close.png";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import nodePng from "assets/node.png";
+import TabSelector from "components/TabSelector";
+import { pushFiltered } from "shared/routing";
+import NodeUsage from "./NodeUsage";
+import { ConditionsTable } from "./ConditionsTable";
+import StatusSection from "components/StatusSection";
+
+type ExpandedNodeViewParams = {
+  nodeId: string;
+};
+
+type TabEnum = "conditions";
+
+const tabOptions: {
+  label: string;
+  value: TabEnum;
+}[] = [{ label: "Conditions", value: "conditions" }];
+
+export const ExpandedNodeView = () => {
+  const { nodeId } = useParams<ExpandedNodeViewParams>();
+  const history = useHistory();
+  const location = useLocation();
+  const { currentCluster, currentProject } = useContext(Context);
+  const [node, setNode] = useState(undefined);
+  const [currentTab, setCurrentTab] = useState("conditions");
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getClusterNode(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          nodeName: nodeId,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setNode(res.data);
+        }
+      });
+
+    return () => (isSubscribed = false);
+  }, [nodeId, currentCluster.id, currentProject.id]);
+
+  const closeNodeView = () => {
+    pushFiltered({ history, location }, "/cluster-dashboard", []);
+  };
+
+  const instanceType = useMemo(() => {
+    const instanceType =
+      node?.labels && node?.labels["node.kubernetes.io/instance-type"];
+    if (instanceType) {
+      return ` (${instanceType})`;
+    }
+    return "";
+  }, [node?.labels]);
+
+  const currentTabPage = useMemo(() => {
+    switch (currentTab) {
+      case "conditions":
+      default:
+        return <ConditionsTable node={node} />;
+    }
+  }, [currentTab, node]);
+
+  const nodeStatus = useMemo(() => {
+    if (!node || !node.node_conditions) {
+      return "loading";
+    }
+
+    return node.node_conditions.reduce((prevValue: boolean, current: any) => {
+      if (current.type !== "Ready" && current.status !== "False") {
+        return "failed";
+      }
+      if (current.type === "Ready" && current.status !== "True") {
+        return "failed";
+      }
+      return prevValue;
+    }, "healthy");
+  }, [node]);
+
+  return (
+    <>
+      <CloseOverlay onClick={closeNodeView} />
+      <StyledExpandedChart>
+        <HeaderWrapper>
+          <TitleSection>
+            <Title>
+              <IconWrapper>
+                <img src={nodePng} />
+              </IconWrapper>
+              {nodeId}
+              <InstanceType>{instanceType}</InstanceType>
+            </Title>
+          </TitleSection>
+
+          <CloseButton onClick={closeNodeView}>
+            <CloseButtonImg src={closeImg} />
+          </CloseButton>
+        </HeaderWrapper>
+        <BodyWrapper>
+          <NodeUsage node={node} />
+
+          <StatusWrapper>
+            <StatusSection status={nodeStatus} />
+          </StatusWrapper>
+
+          <TabSelector
+            options={tabOptions}
+            currentTab={currentTab}
+            setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+          />
+          {currentTabPage}
+        </BodyWrapper>
+      </StyledExpandedChart>
+    </>
+  );
+};
+
+export default ExpandedNodeView;
+
+const StatusWrapper = styled.div`
+  margin-left: 3px;
+  margin-bottom: 15px;
+`;
+
+const InstanceType = styled.div`
+  font-weight: 400;
+  color: #ffffff44;
+  margin-left: 12px;
+`;
+
+const BodyWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div``;
+
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const IconWrapper = styled.div`
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > img {
+    filter: brightness(50%);
+    width: 18px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  user-select: text;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;
+  border-radius: 10px;
+  background: #26272f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 125 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx

@@ -0,0 +1,125 @@
+import React from "react";
+import styled from "styled-components";
+
+type NodeUsageProps = {
+  node: any;
+};
+
+const NodeUsage: React.FunctionComponent<NodeUsageProps> = ({ node }) => {
+  const percentFormatter = (number: number) => `${Number(number).toFixed(2)}%`;
+
+  const formatMemoryUnitToMi = (memory: string) => {
+    if (memory.includes("Mi")) {
+      return memory;
+    }
+
+    if (memory.includes("Gi")) {
+      const [value] = memory.split("Gi");
+      const numValue = Number(value);
+      const giToMiValue = numValue * 1024;
+      return `${giToMiValue.toFixed()}Mi`;
+    }
+
+    if (memory.includes("Ki")) {
+      const [value] = memory.split("Ki");
+      const numValue = Number(value);
+      const kiToMiValue = numValue / 1024;
+      return `${kiToMiValue.toFixed()}Mi`;
+    }
+
+    const value = memory.replace(/[^0-9]/g, "");
+    const numValue = Number(value);
+    const unknownToMiValue = numValue * 1024 * 1024;
+    return `${unknownToMiValue.toFixed()}Mi`;
+  };
+
+  return (
+    <NodeUsageWrapper>
+      <Wrapper>
+        <UsageWrapper>
+          <span>
+            <Bolded>CPU:</Bolded>{" "}
+            {!node?.cpu_reqs && !node?.allocatable_cpu
+              ? "Loading..."
+              : `${percentFormatter(node?.fraction_cpu_reqs)} (${node?.cpu_reqs}/${
+                  node?.allocatable_cpu
+                }m)`}
+          </span>
+          <Buffer />
+          <span>
+            <Bolded>RAM:</Bolded>{" "}
+            {!node?.memory_reqs && !node?.allocatable_memory
+              ? "Loading..."
+              : `${percentFormatter(node?.fraction_memory_reqs)} (${formatMemoryUnitToMi(
+                  node?.memory_reqs
+                )}/${formatMemoryUnitToMi(
+                  node?.allocatable_memory
+                )})`}
+          </span>
+          <I onClick={() => window.open("https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable")} className="material-icons">help_outline</I>
+        </UsageWrapper>
+      </Wrapper>
+    </NodeUsageWrapper>
+  );
+};
+
+const I = styled.i`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  font-size: 17px;
+  margin-left: 12px;
+  color: #858faaaa;
+  :hover {
+    color: #aaaabb;
+  }
+`;
+
+const Buffer = styled.div`
+  width: 17px;
+  height: 20px;
+`;
+
+const Wrapper = styled.div`
+  display: flex;
+`;
+
+const UsageWrapper = styled.div`
+  display: flex;
+  flex-direction: row;
+  font-size: 14px;
+  color: #aaaabb;
+  line-height: 24px;
+  user-select: text;
+  :not(last-child) {
+    margin-right: 20px;
+  }
+`;
+
+const Bolded = styled.span`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+`;
+
+const Help = styled.a`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  margin-bottom: 5px;
+  width: fit-content;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-left: 5px;
+    font-size: 16px;
+  }
+`;
+
+const NodeUsageWrapper = styled.div`
+  margin: 14px 0px 10px;
+`;
+
+export default NodeUsage;

+ 13 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx

@@ -5,8 +5,13 @@ import key from "assets/key.svg";
 
 import { Context } from "shared/Context";
 
+export type EnvGroupData = {
+  data: Record<string, string>;
+  metadata: any;
+};
+
 type PropsType = {
-  envGroup: any;
+  envGroup: EnvGroupData;
   setExpanded: () => void;
 };
 
@@ -71,6 +76,13 @@ export default class EnvGroup extends Component<PropsType, StateType> {
   }
 }
 
+export function formattedEnvironmentValue(value: string) {
+  if (value.startsWith("PORTERSECRET_")) {
+    return "••••";
+  }
+  return value;
+}
+
 EnvGroup.contextType = Context;
 
 const BottomWrapper = styled.div`

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -204,7 +204,8 @@ export default class EnvGroupArray extends Component<PropsType, StateType> {
     for (let key in envObj) {
       for (var i = 0; i < this.props.values.length; i++) {
         let existingKey = this.props.values[i]["key"];
-        if (key === existingKey) {
+        let isExistingKeyDeleted = this.props.values[i]["deleted"];
+        if (key === existingKey && !isExistingKeyDeleted) {
           _values[i]["value"] = envObj[key];
           push = false;
         }

+ 26 - 13
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -14,10 +14,12 @@ import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushQueryParams } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-};
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+  };
 
 type StateType = {
   expand: boolean;
@@ -59,16 +61,22 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         />
       );
     } else {
+      const isAuthorizedToAdd = this.props.isAuthorized("env_group", "", [
+        "get",
+        "create",
+      ]);
       return (
         <>
-          <ControlRow>
-            <Button
-              onClick={() =>
-                this.setState({ createEnvMode: !this.state.createEnvMode })
-              }
-            >
-              <i className="material-icons">add</i> Create Env Group
-            </Button>
+          <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+            {isAuthorizedToAdd && (
+              <Button
+                onClick={() =>
+                  this.setState({ createEnvMode: !this.state.createEnvMode })
+                }
+              >
+                <i className="material-icons">add</i> Create Env Group
+              </Button>
+            )}
             <SortFilterWrapper>
               <SortSelector
                 setSortType={(sortType) => this.setState({ sortType })}
@@ -131,7 +139,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
 
 EnvGroupDashboard.contextType = Context;
 
-export default withRouter(EnvGroupDashboard);
+export default withRouter(withAuth(EnvGroupDashboard));
 
 const SortFilterWrapper = styled.div`
   width: 468px;
@@ -141,7 +149,12 @@ const SortFilterWrapper = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;

+ 57 - 22
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -15,8 +15,9 @@ import TabRegion from "components/TabRegion";
 import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   envGroup: any;
   currentCluster: ClusterType;
@@ -30,6 +31,7 @@ type StateType = {
   deleting: boolean;
   saveValuesStatus: string | null;
   envVariables: KeyValueType[];
+  tabOptions: { value: string; label: string }[];
 };
 
 const tabOptions = [
@@ -37,7 +39,7 @@ const tabOptions = [
   { value: "settings", label: "Settings" },
 ];
 
-export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
+class ExpandedEnvGroup extends Component<PropsType, StateType> {
   state = {
     loading: true,
     currentTab: "environment",
@@ -45,6 +47,10 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     envVariables: [] as KeyValueType[],
+    tabOptions: [
+      { value: "environment", label: "Environment Variables" },
+      { value: "settings", label: "Settings" },
+    ],
   };
 
   componentDidMount() {
@@ -63,6 +69,21 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     }
 
     this.setState({ envVariables });
+
+    // Filter the settings tab options as for now it only shows the delete button.
+    // In a future this should be removed and return to a constant if we want to show data
+    // inside the settings tab. (This is make to avoid confussion for the user)
+    this.setState((prevState) => {
+      return {
+        ...prevState,
+        tabOptions: prevState.tabOptions.filter((option) => {
+          if (option.value === "settings") {
+            return this.props.isAuthorized("env_group", "", ["get", "delete"]);
+          }
+          return true;
+        }),
+      };
+    });
   }
 
   handleUpdateValues = () => {
@@ -170,32 +191,44 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 setValues={(x: any) => this.setState({ envVariables: x })}
                 fileUpload={true}
                 secretOption={true}
+                disabled={
+                  !this.props.isAuthorized("env_group", "", [
+                    "get",
+                    "create",
+                    "delete",
+                    "update",
+                  ])
+                }
               />
             </InnerWrapper>
-            <SaveButton
-              text="Update"
-              onClick={() => this.handleUpdateValues()}
-              status={this.state.saveValuesStatus}
-              makeFlush={true}
-            />
+            {this.props.isAuthorized("env_group", "", ["get", "update"]) && (
+              <SaveButton
+                text="Update"
+                onClick={() => this.handleUpdateValues()}
+                status={this.state.saveValuesStatus}
+                makeFlush={true}
+              />
+            )}
           </TabWrapper>
         );
       default:
         return (
           <TabWrapper>
-            <InnerWrapper full={true}>
-              <Heading>Manage Environment Group</Heading>
-              <Helper>
-                Permanently delete this set of environment variables. This
-                action cannot be undone.
-              </Helper>
-              <Button
-                color="#b91133"
-                onClick={() => this.setState({ showDeleteOverlay: true })}
-              >
-                Delete {name}
-              </Button>
-            </InnerWrapper>
+            {this.props.isAuthorized("env_group", "", ["get", "delete"]) && (
+              <InnerWrapper full={true}>
+                <Heading>Manage Environment Group</Heading>
+                <Helper>
+                  Permanently delete this set of environment variables. This
+                  action cannot be undone.
+                </Helper>
+                <Button
+                  color="#b91133"
+                  onClick={() => this.setState({ showDeleteOverlay: true })}
+                >
+                  Delete {name}
+                </Button>
+              </InnerWrapper>
+            )}
           </TabWrapper>
         );
     }
@@ -292,7 +325,7 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
           <TabRegion
             currentTab={this.state.currentTab}
             setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-            options={tabOptions}
+            options={this.state.tabOptions}
             color={null}
           >
             {this.renderTabContents()}
@@ -305,6 +338,8 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
 
 ExpandedEnvGroup.contextType = Context;
 
+export default withAuth(ExpandedEnvGroup);
+
 const Button = styled.button`
   height: 35px;
   font-size: 13px;

+ 92 - 8
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import yaml from "js-yaml";
 import close from "assets/close.png";
 import _ from "lodash";
+import loading from "assets/loading.gif";
 
 import {
   ResourceType,
@@ -25,8 +26,9 @@ import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
 import ChartList from "../chart/ChartList";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
@@ -53,9 +55,11 @@ type StateType = {
   showDeleteOverlay: boolean;
   deleting: boolean;
   formData: any;
+  imageIsPlaceholder: boolean;
+  newestImage: string;
 };
 
-export default class ExpandedChart extends Component<PropsType, StateType> {
+class ExpandedChart extends Component<PropsType, StateType> {
   state = {
     currentChart: this.props.currentChart,
     loading: true,
@@ -74,6 +78,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     showDeleteOverlay: false,
     deleting: false,
     formData: {} as any,
+    imageIsPlaceholder: false,
+    newestImage: null as string,
   };
 
   // Retrieve full chart data (includes form and values)
@@ -97,8 +103,24 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
+        let image = res.data?.config?.image?.repository;
+        let tag = res.data?.config?.image?.tag?.toString();
+        let newestImage = tag ? image + ":" + tag : image;
+        let imageIsPlaceholder = false;
+        if (
+          (image === "porterdev/hello-porter" ||
+            image === "public.ecr.aws/o1j4x7p4/hello-porter") &&
+          !this.state.newestImage
+        ) {
+          imageIsPlaceholder = true;
+        }
         this.updateComponents(
-          { currentChart: res.data, loading: false },
+          {
+            currentChart: res.data,
+            loading: false,
+            imageIsPlaceholder,
+            newestImage,
+          },
           res.data
         );
       })
@@ -239,6 +261,11 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       values = this.props.currentChart.config;
     }
 
+    // Override config from currentChart prop if we have it on the current state
+    if (this.state.currentChart.config) {
+      values = this.state.currentChart.config;
+    }
+
     for (let key in rawValues) {
       _.set(values, key, rawValues[key]);
     }
@@ -362,7 +389,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
 
   renderTabContents = (currentTab: string) => {
-    let { components, showRevisions } = this.state;
+    let { components, showRevisions, imageIsPlaceholder } = this.state;
     let { setSidebar } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
@@ -371,7 +398,22 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "status":
-        return <StatusSection currentChart={chart} />;
+        if (imageIsPlaceholder) {
+          return (
+            <Placeholder>
+              <TextWrap>
+                <Header>
+                  <Spinner src={loading} /> This application is currently being
+                  deployed
+                </Header>
+                Navigate to the "Actions" tab of your GitHub repo to view live
+                build logs.
+              </TextWrap>
+            </Placeholder>
+          );
+        } else {
+          return <StatusSection currentChart={chart} />;
+        }
       case "settings":
         return (
           <SettingsSection
@@ -403,7 +445,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         );
       case "values":
         return (
-          <ValuesYaml currentChart={chart} refreshChart={this.refreshChart} />
+          <ValuesYaml
+            currentChart={chart}
+            refreshChart={this.refreshChart}
+            disabled={
+              !this.props.isAuthorized("application", "", ["get", "update"])
+            }
+          />
         );
       default:
     }
@@ -433,7 +481,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     }
 
     // Settings tab is always last
-    tabOptions.push({ label: "Settings", value: "settings" });
+    if (this.props.isAuthorized("application", "", ["get", "delete"])) {
+      tabOptions.push({ label: "Settings", value: "settings" });
+    }
 
     // Filter tabs if previewing an old revision or updating the chart version
     if (this.state.isPreview || this.state.isUpdatingChart) {
@@ -746,6 +796,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           </HeaderWrapper>
           <BodyWrapper>
             <FormWrapper
+              isReadOnly={
+                this.state.imageIsPlaceholder ||
+                !this.props.isAuthorized("application", "", ["get", "update"])
+              }
               formData={this.state.formData}
               tabOptions={this.state.tabOptions}
               isInModal={true}
@@ -775,6 +829,36 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 ExpandedChart.contextType = Context;
 
+export default withAuth(ExpandedChart);
+
+const TextWrap = styled.div``;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Placeholder = styled.div`
+  height: 100%;
+  padding: 30px;
+  padding-bottom: 90px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
 const BodyWrapper = styled.div`
   width: 100%;
   height: 100%;
@@ -894,7 +978,7 @@ const Dot = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
-  margin-left: 6px;
+  margin-left: 3px;
   margin-top: 22px;
 `;
 

+ 27 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -17,8 +17,9 @@ import JobList from "./jobs/JobList";
 import SettingsSection from "./SettingsSection";
 import FormWrapper from "components/values-form/FormWrapper";
 import { PlaceHolder } from "brace";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
@@ -43,7 +44,7 @@ type StateType = {
   valuesToOverride: any;
 };
 
-export default class ExpandedJobChart extends Component<PropsType, StateType> {
+class ExpandedJobChart extends Component<PropsType, StateType> {
   state = {
     currentChart: this.props.currentChart,
     imageIsPlaceholder: false,
@@ -429,6 +430,10 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       />
     );
 
+    if (!this.props.isAuthorized("job", "", ["get", "update", "create"])) {
+      saveButton = null;
+    }
+
     switch (currentTab) {
       case "jobs":
         if (this.state.imageIsPlaceholder) {
@@ -457,15 +462,17 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         );
       case "settings":
         return (
-          <SettingsSection
-            showSource={true}
-            currentChart={this.state.currentChart}
-            refreshChart={() => this.refreshChart(0)}
-            setShowDeleteOverlay={(x: boolean) =>
-              this.setState({ showDeleteOverlay: x })
-            }
-            saveButtonText="Save Config"
-          />
+          this.props.isAuthorized("job", "", ["get", "delete"]) && (
+            <SettingsSection
+              showSource={true}
+              currentChart={this.state.currentChart}
+              refreshChart={() => this.refreshChart(0)}
+              setShowDeleteOverlay={(x: boolean) =>
+                this.setState({ showDeleteOverlay: x })
+              }
+              saveButtonText="Save Config"
+            />
+          )
         );
       default:
     }
@@ -494,7 +501,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       });
     }
 
-    tabOptions.push({ label: "Settings", value: "settings" });
+    if (this.props.isAuthorized("job", "", ["get", "delete"])) {
+      tabOptions.push({ label: "Settings", value: "settings" });
+    }
 
     // Filter tabs if previewing an old revision
     this.setState({ tabOptions });
@@ -612,7 +621,10 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
           <BodyWrapper>
             <FormWrapper
-              isReadOnly={this.state.imageIsPlaceholder}
+              isReadOnly={
+                this.state.imageIsPlaceholder ||
+                !this.props.isAuthorized("job", "", ["get", "update"])
+              }
               valuesToOverride={this.state.valuesToOverride}
               clearValuesToOverride={() =>
                 this.setState({ valuesToOverride: {} })
@@ -637,6 +649,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
 ExpandedJobChart.contextType = Context;
 
+export default withAuth(ExpandedJobChart);
+
 const TextWrap = styled.div``;
 
 const Header = styled.div`

+ 69 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -7,8 +7,9 @@ import { Context } from "shared/Context";
 import { ChartType, StorageType } from "shared/types";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   showRevisions: boolean;
   toggleShowRevisions: () => void;
   chart: ChartType;
@@ -31,7 +32,7 @@ type StateType = {
 };
 
 // TODO: handle refresh when new revision is generated from an old revision
-export default class RevisionSection extends Component<PropsType, StateType> {
+class RevisionSection extends Component<PropsType, StateType> {
   state = {
     revisions: [] as ChartType[],
     rollbackRevision: null as number | null,
@@ -67,6 +68,66 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
   componentDidMount() {
     this.refreshHistory();
+    this.connectToLiveUpdates();
+  }
+
+  connectToLiveUpdates() {
+    let { chart } = this.props;
+    let { currentCluster, currentProject } = this.context;
+
+    const apiPath = `/api/projects/${currentProject.id}/k8s/helm_releases?cluster_id=${currentCluster.id}&charts=${chart.name}`;
+    const protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    const url = `${protocol}://${window.location.host}`;
+
+    const ws = new WebSocket(`${url}${apiPath}`);
+
+    ws.onopen = () => {
+      console.log("connected to chart live updates websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+
+      if (event.event_type == "UPDATE") {
+        let object = event.Object;
+
+        this.setState(
+          (prevState) => {
+            const { revisions: oldRevisions } = prevState;
+            // Copy old array to clean up references
+            const prevRevisions = [...oldRevisions];
+
+            // Check if it's an update of a revision or if it's a new one
+            const revisionIndex = prevRevisions.findIndex((rev) => {
+              if (rev.version === object.version) {
+                return true;
+              }
+            });
+
+            // Place new one at top of the array or update the old one
+            if (revisionIndex > -1) {
+              prevRevisions.splice(revisionIndex, 1, object);
+            } else {
+              return { ...prevState, revisions: [object, ...prevRevisions] };
+            }
+
+            return { ...prevState, revisions: prevRevisions };
+          },
+          () => {
+            this.props.setRevision(this.state.revisions[0], true);
+          }
+        );
+      }
+    };
+
+    ws.onclose = () => {
+      console.log("closing chart live updates websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
   }
 
   // Handle update of values.yaml
@@ -167,7 +228,10 @@ export default class RevisionSection extends Component<PropsType, StateType> {
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
-              disabled={isCurrent}
+              disabled={
+                isCurrent ||
+                !this.props.isAuthorized("application", "", ["get", "update"])
+              }
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })
               }
@@ -281,6 +345,8 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
 RevisionSection.contextType = Context;
 
+export default withAuth(RevisionSection);
+
 const TableWrapper = styled.div`
   padding-bottom: 20px;
 `;

+ 10 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -12,6 +12,7 @@ import SaveButton from "components/SaveButton";
 type PropsType = {
   currentChart: ChartType;
   refreshChart: () => void;
+  disabled?: boolean;
 };
 
 type StateType = {
@@ -89,14 +90,17 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
           <YamlEditor
             value={this.state.values}
             onChange={(e: any) => this.setState({ values: e })}
+            readOnly={this.props.disabled}
           />
         </Wrapper>
-        <SaveButton
-          text="Update Values"
-          onClick={this.handleSaveValues}
-          status={this.state.saveValuesStatus}
-          makeFlush={true}
-        />
+        {!this.props.disabled && (
+          <SaveButton
+            text="Update Values"
+            onClick={this.handleSaveValues}
+            status={this.state.saveValuesStatus}
+            makeFlush={true}
+          />
+        )}
       </StyledValuesYaml>
     );
   }

+ 12 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -6,8 +6,9 @@ import _ from "lodash";
 import { Context } from "shared/Context";
 import JobResource from "./JobResource";
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   jobs: any[];
   setJobs: (job: any) => void;
 };
@@ -17,7 +18,7 @@ type StateType = {
   deletionJob: any;
 };
 
-export default class JobList extends Component<PropsType, StateType> {
+class JobList extends Component<PropsType, StateType> {
   state = {
     deletionCandidate: null as any,
     deletionJob: null as any,
@@ -43,6 +44,13 @@ export default class JobList extends Component<PropsType, StateType> {
                 deleting={
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
                 }
+                readOnly={
+                  !this.props.isAuthorized("job", "", [
+                    "get",
+                    "update",
+                    "delete",
+                  ])
+                }
               />
             );
           })}
@@ -100,6 +108,8 @@ export default class JobList extends Component<PropsType, StateType> {
 
 JobList.contextType = Context;
 
+export default withAuth(JobList);
+
 const Placeholder = styled.div`
   width: 100%;
   height: 100%;

+ 16 - 9
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -14,6 +14,7 @@ type PropsType = {
   job: any;
   handleDelete: () => void;
   deleting: boolean;
+  readOnly?: boolean;
 };
 
 type StateType = {
@@ -243,6 +244,10 @@ export default class JobResource extends Component<PropsType, StateType> {
   };
 
   renderStopButton = () => {
+    if (this.props.readOnly) {
+      return null;
+    }
+
     if (!this.props.job.status?.succeeded && !this.props.job.status?.failed) {
       // look for a sidecar container
       if (this.props.job?.spec?.template?.spec?.containers.length == 2) {
@@ -281,15 +286,17 @@ export default class JobResource extends Component<PropsType, StateType> {
               {this.renderStatus()}
               <MaterialIconTray disabled={false}>
                 {this.renderStopButton()}
-                <i
-                  className="material-icons"
-                  onClick={(e) => {
-                    e.stopPropagation();
-                    this.props.handleDelete();
-                  }}
-                >
-                  delete
-                </i>
+                {!this.props.readOnly && (
+                  <i
+                    className="material-icons"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      this.props.handleDelete();
+                    }}
+                  >
+                    delete
+                  </i>
+                )}
                 <i className="material-icons" onClick={this.expandJob}>
                   {this.state.expanded ? "expand_less" : "expand_more"}
                 </i>

+ 28 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -139,6 +139,8 @@ export default class Logs extends Component<PropsType, StateType> {
     if (prevState.currentTab !== this.state.currentTab) {
       let { selectedPod } = this.props;
 
+      this.ws?.close();
+
       this.setState({ logs: [] });
 
       if (this.state.currentTab == "Application") {
@@ -225,6 +227,32 @@ export default class Logs extends Component<PropsType, StateType> {
               System
             </Tab>
           </LogTabs>
+          <Options>
+            <Scroll
+              onClick={() => {
+                this.setState({ scroll: !this.state.scroll }, () => {
+                  if (this.state.scroll) {
+                    this.scrollToBottom(true);
+                  }
+                });
+              }}
+            >
+              <input
+                type="checkbox"
+                checked={this.state.scroll}
+                onChange={() => {}}
+              />
+              Scroll to Bottom
+            </Scroll>
+            <Refresh
+              onClick={() => {
+                this.refreshLogs();
+              }}
+            >
+              <i className="material-icons">autorenew</i>
+              Refresh
+            </Refresh>
+          </Options>
         </LogStreamAlt>
       );
     }

+ 18 - 14
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -14,11 +14,13 @@ import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/values-form/FormDebugger";
 
 import { pushQueryParams, pushFiltered } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = RouteComponentProps & {
-  projectId: number | null;
-  setRefreshClusters: (x: boolean) => void;
-};
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    projectId: number | null;
+    setRefreshClusters: (x: boolean) => void;
+  };
 
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
@@ -126,11 +128,13 @@ class Dashboard extends Component<PropsType, StateType> {
     let { currentProject, capabilities } = this.context;
     let { onShowProjectSettings } = this;
 
-    let tabOptions = [
-      { label: "Project Overview", value: "overview" },
-      { label: "Create a Cluster", value: "create-cluster" },
-      { label: "Provisioner Status", value: "provisioner" },
-    ];
+    let tabOptions = [{ label: "Project Overview", value: "overview" }];
+
+    if (this.props.isAuthorized("cluster", "", ["get", "create"])) {
+      tabOptions.push({ label: "Create a Cluster", value: "create-cluster" });
+    }
+
+    tabOptions.push({ label: "Provisioner Status", value: "provisioner" });
 
     if (!capabilities?.provisioner) {
       tabOptions = [{ label: "Project Overview", value: "overview" }];
@@ -154,9 +158,9 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                   </DashboardIcon>
                   <Title>{currentProject && currentProject.name}</Title>
-                  {this.context.currentProject.roles.filter((obj: any) => {
+                  {this.context.currentProject?.roles?.filter((obj: any) => {
                     return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" && (
+                  })[0].kind === "admin" || (
                     <i
                       className="material-icons"
                       onClick={onShowProjectSettings}
@@ -195,7 +199,7 @@ class Dashboard extends Component<PropsType, StateType> {
 
 Dashboard.contextType = Context;
 
-export default withRouter(Dashboard);
+export default withRouter(withAuth(Dashboard));
 
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
@@ -318,8 +322,8 @@ const TitleSection = styled.div`
   > i {
     margin-left: 10px;
     cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
+    font-size: 18px;
+    color: #858faaaa;
     padding: 5px;
     border-radius: 100px;
     :hover {

+ 1 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -134,7 +134,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
             label={label}
             toggleCollapse={(e: MouseEvent) => this.toggleDisplay(e, i)}
             triggerDelete={(e: MouseEvent) => this.triggerDelete(e, i, item_id)}
-          ></IntegrationRow>
+          />
         );
       });
     } else if (integrations && integrations.length > 0) {

+ 1 - 5
dashboard/src/main/home/integrations/Integrations.tsx

@@ -72,11 +72,7 @@ class Integrations extends Component<PropsType, StateType> {
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
               pushFiltered(this.props, "/integrations", ["project_id"]);
             }
-            return (
-              <IntegrationCategories
-                category={currentCategory}
-              ></IntegrationCategories>
-            );
+            return <IntegrationCategories category={currentCategory} />;
           }}
         />
         <Route>

+ 2 - 2
dashboard/src/main/home/launch/Launch.tsx

@@ -11,7 +11,7 @@ import Loading from "components/Loading";
 import LaunchFlow from "./launch-flow/LaunchFlow";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 
-import hardcodedNames from "./hardcodedNameDict";
+import { hardcodedNames } from "shared/hardcodedNameDict";
 import semver from "semver";
 
 const tabOptions = [
@@ -421,7 +421,7 @@ const TitleSection = styled.div`
       align-items: center;
       margin-bottom: -2px;
       font-size: 18px;
-      margin-left: 18px;
+      margin-left: 15px;
       color: #858faaaa;
       :hover {
         color: #aaaabb;

+ 1 - 1
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -9,7 +9,7 @@ import { PorterTemplate } from "shared/types";
 import Helper from "components/values-form/Helper";
 import Selector from "components/Selector";
 
-import hardcodedNames from "../hardcodedNameDict";
+import { hardcodedNames } from "shared/hardcodedNameDict";
 
 type PropsType = {
   currentTemplate: any;

+ 0 - 20
dashboard/src/main/home/launch/hardcodedNameDict.tsx

@@ -1,20 +0,0 @@
-const hardcodedNames: { [key: string]: string } = {
-  docker: "Docker",
-  "https-issuer": "HTTPS Issuer",
-  metabase: "Metabase",
-  mongodb: "MongoDB",
-  mysql: "MySQL",
-  postgresql: "PostgreSQL",
-  redis: "Redis",
-  ubuntu: "Ubuntu",
-  web: "Web Service",
-  worker: "Worker",
-  job: "Job",
-  "cert-manager": "Cert Manager",
-  elasticsearch: "Elasticsearch",
-  prometheus: "Prometheus",
-  rabbitmq: "RabbitMQ",
-  logdna: "LogDNA",
-};
-
-export default hardcodedNames;

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -8,7 +8,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { pushFiltered } from "shared/routing";
 
-import hardcodedNames from "../hardcodedNameDict";
+import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
 import SettingsPage from "./SettingsPage";
 

+ 12 - 3
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -19,8 +19,9 @@ import Helper from "components/values-form/Helper";
 import FormWrapper from "components/values-form/FormWrapper";
 import Selector from "components/Selector";
 import Loading from "components/Loading";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   onSubmit: (x?: any) => void;
   hasSource: boolean;
   setPage: (x: string) => void;
@@ -44,7 +45,7 @@ type StateType = {
   namespaceOptions: { label: string; value: string }[];
 };
 
-export default class SettingsPage extends Component<PropsType, StateType> {
+class SettingsPage extends Component<PropsType, StateType> {
   state = {
     tabOptions: [] as ChoiceType[],
     currentTab: "",
@@ -152,6 +153,9 @@ export default class SettingsPage extends Component<PropsType, StateType> {
               clusterId: this.context.currentCluster.id,
               isLaunch: true,
             }}
+            isReadOnly={
+              !this.props.isAuthorized("namespace", "", ["get", "create"])
+            }
             onSubmit={onSubmit}
           />
         </>
@@ -261,7 +265,10 @@ export default class SettingsPage extends Component<PropsType, StateType> {
               refreshOptions={() => {
                 this.updateNamespaces(this.context.currentCluster.id);
               }}
-              addButton={true}
+              addButton={this.props.isAuthorized("namespace", "", [
+                "get",
+                "create",
+              ])}
               activeValue={selectedNamespace}
               setActiveValue={setSelectedNamespace}
               options={this.state.namespaceOptions}
@@ -279,6 +286,8 @@ export default class SettingsPage extends Component<PropsType, StateType> {
 
 SettingsPage.contextType = Context;
 
+export default withAuth(SettingsPage);
+
 const LoadingWrapper = styled.div`
   margin-top: 80px;
 `;

+ 266 - 0
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -0,0 +1,266 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import close from "assets/close.png";
+import github from "assets/github.png";
+
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import Loading from "../../../components/Loading";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+import TabSelector from "components/TabSelector";
+
+interface GithubAppAccessData {
+  has_access: boolean;
+  username?: string;
+  accounts?: string[];
+}
+
+const tabOptions = [{ label: "Integrations", value: "integrations" }];
+
+const AccountSettingsModal = () => {
+  const { setCurrentModal } = useContext(Context);
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({
+    has_access: false,
+  });
+  const [currentTab, setCurrentTab] = useState("integrations");
+
+  useEffect(() => {
+    api
+      .getGithubAccess("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+  }, []);
+
+  return (
+    <>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+      <ModalTitle>
+        Account Settings
+      </ModalTitle>
+
+      <TabSelector
+        options={tabOptions}
+        currentTab={currentTab}
+        setCurrentTab={(value: string) =>
+          setCurrentTab(value)
+        }
+      />
+
+      <Heading>
+        <GitIcon src={github} /> Github
+      </Heading>
+      {accessError ? (
+        <Placeholder>An error has occured.</Placeholder>
+      ) : accessLoading ? (
+        <LoadingWrapper>
+          {" "}
+          <Loading />
+        </LoadingWrapper>
+      ) : (
+        <>
+          {/* Will be styled (and show what account is connected) later */}
+          {accessData.has_access ? (
+            <Placeholder>
+              <User>
+                You are currently authorized as <B>{accessData.username}</B> and have access to:
+              </User>
+              {!accessData.accounts || accessData.accounts?.length == 0 ? (
+                <ListWrapper>
+                  <Helper>
+                    No connected repositories found.
+                    <A href={"/api/integrations/github-app/install"}>
+                      Install Porter in your repositories
+                    </A>
+                  </Helper>
+                </ListWrapper>
+              ) : (
+                <>
+                  <List>
+                    {accessData.accounts.map((name, i) => {
+                      return (
+                        <React.Fragment key={i}>
+                          <Row isLastItem={i === accessData.accounts.length - 1}>
+                            <i className="material-icons">bookmark</i>
+                            {name}
+                          </Row>
+                        </React.Fragment>
+                      );
+                    })}
+                  </List>
+                  <br />
+                  Don't see the right repos?{" "}
+                  <A href={"/api/integrations/github-app/install"}>
+                    Install Porter in more repositories
+                  </A>
+                </>
+              )}
+            </Placeholder>
+          ) : (
+            <ListWrapper>
+              <Helper>
+                No github integration detected. You can
+                <A href={"/api/integrations/github-app/authorize"}>
+                  connect your GitHub account
+                </A>
+              </Helper>
+            </ListWrapper>
+          )}
+        </>
+      )}
+    </>
+  );
+};
+
+export default AccountSettingsModal;
+
+const B = styled.b`
+  color: #ffffff;
+`;
+
+const User = styled.div`
+  margin-top: 14px;
+  font-size: 13px;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  height: 200px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 40px;
+`;
+
+const List = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  border-radius: 5px;
+  margin-top: 20px;
+  border: 1px solid #ffffff44;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLastItem?: boolean }>`
+  width: 100%;
+  height: 35px;
+  color: #ffffff55;
+  display: flex;
+  align-items: center;
+  border-bottom: ${props => props.isLastItem ? "" : "1px solid #ffffff44"};
+  > i {
+    font-size: 17px;
+    margin-left: 10px;
+    margin-right: 12px;
+    color: #ffffff44;
+  }
+`;
+
+const GitIcon = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 10px;
+  filter: brightness(120%);
+  margin-left: 1px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 18px;
+    margin-top: 1px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 0;
+  }
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 50px;
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  line-height: 1.6em;
+  user-select: none;
+`;

+ 1 - 1
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -224,7 +224,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -136,7 +136,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 176 - 0
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -0,0 +1,176 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import SaveButton from "components/SaveButton";
+import { Context } from "shared/Context";
+import RadioSelector from "components/RadioSelector";
+import api from "shared/api";
+import { setTimeout } from "timers";
+
+const EditCollaboratorModal = () => {
+  const {
+    setCurrentModal,
+    currentModalData: { user, isInvite, refetchCallerData },
+    currentProject: { id: project_id },
+  } = useContext(Context);
+  const [status, setStatus] = useState<undefined | string>();
+  const [selectedRole, setSelectedRole] = useState("");
+  const [roleList, setRoleList] = useState([]);
+
+  useEffect(() => {
+    api
+      .getAvailableRoles("<token>", {}, { project_id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setSelectedRole(user?.kind || "developer");
+      });
+  }, []);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
+  };
+
+  const handleUpdate = () => {
+    if (isInvite) {
+      updateInvite();
+    } else {
+      updateCollaborator();
+    }
+  };
+
+  const updateCollaborator = async () => {
+    setStatus("loading");
+    try {
+      await api.updateCollaborator(
+        "<token>",
+        { kind: selectedRole },
+        { project_id, user_id: user.id }
+      );
+      setStatus("successful");
+      refetchCallerData().then(() => {
+        setTimeout(() => setCurrentModal(null, null), 500);
+      });
+    } catch (error) {
+      setStatus("error");
+    }
+  };
+
+  const updateInvite = async () => {
+    setStatus("loading");
+    try {
+      await api.updateInvite(
+        "<token>",
+        { kind: selectedRole },
+        { project_id, invite_id: user.id }
+      );
+      setStatus("successful");
+      refetchCallerData().then(() => {
+        setTimeout(() => setCurrentModal(null, null), 500);
+      });
+    } catch (error) {
+      setStatus("error");
+    }
+  };
+
+  return (
+    <StyledUpdateProjectModal>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+
+      <ModalTitle>
+        Update {isInvite ? "Invite for" : "Collaborator"} {user?.email}
+      </ModalTitle>
+      <Subtitle>Specify a different role for this user.</Subtitle>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={selectedRole}
+          setSelected={setSelectedRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
+
+      <SaveButton
+        text={`Update ${isInvite ? "Invite" : "Collaborator"}`}
+        color="#616FEEcc"
+        onClick={() => handleUpdate()}
+        status={status}
+      />
+    </StyledUpdateProjectModal>
+  );
+};
+
+export default EditCollaboratorModal;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+  margin-top: 25px;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 1 - 1
dashboard/src/main/home/modals/EnvEditorModal.tsx

@@ -123,7 +123,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx

@@ -152,7 +152,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -140,7 +140,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 178 - 13
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -1,5 +1,5 @@
 import React, { Component } from "react";
-import styled from "styled-components";
+import styled, { css } from "styled-components";
 import close from "assets/close.png";
 import sliders from "assets/sliders.svg";
 
@@ -8,19 +8,25 @@ import { Context } from "shared/Context";
 
 import Loading from "components/Loading";
 import SaveButton from "components/SaveButton";
+import { KeyValue } from "components/values-form/KeyValueArray";
+import {
+  EnvGroupData,
+  formattedEnvironmentValue,
+} from "../cluster-dashboard/env-groups/EnvGroup";
 
 type PropsType = {
   namespace: string;
   clusterId: number;
   closeModal: () => void;
-  setValues: (values: any) => void;
+  existingValues: Record<string, string>;
+  setValues: (values: Record<string, string>) => void;
 };
 
 type StateType = {
   envGroups: any[];
   loading: boolean;
   error: boolean;
-  selectedEnvGroup: any;
+  selectedEnvGroup: EnvGroupData | null;
   buttonStatus: string;
 };
 
@@ -29,7 +35,7 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
     envGroups: [] as any[],
     loading: true,
     error: false,
-    selectedEnvGroup: null as any,
+    selectedEnvGroup: null as EnvGroupData | null,
     buttonStatus: "",
   };
 
@@ -96,7 +102,56 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
     }
   };
 
+  potentiallyOverriddenKeys(incoming: Record<string, string>): KeyValue[] {
+    return Object.entries(incoming)
+      .filter(([key]) => this.props.existingValues[key])
+      .map(([key, value]) => ({ key, value }));
+  }
+
+  saveButtonStatus(hasClashingKeys: boolean): string {
+    if (!this.state.selectedEnvGroup) {
+      return "No env group selected";
+    }
+    if (hasClashingKeys) {
+      return "There are variables defined in this group that will override existing variables.";
+    }
+  }
+
+  renderEnvGroupPreview(clashingKeys: KeyValue[]) {
+    const emptyValue = <i>Empty value</i>;
+    return (
+      <PossibleClashingKeys>
+        {clashingKeys.map(({ key, value }, i) => (
+          <ClashingKeyItem key={key}>
+            <ClashingKeyTop>
+              <ClashIconWrapper>
+                <ClashIcon className="material-icons">sync_problem</ClashIcon>
+              </ClashIconWrapper>
+              <ClashingKeyExplanation>
+                <b>{key}</b> is defined in both environments
+              </ClashingKeyExplanation>
+            </ClashingKeyTop>
+            <ClashingKeyDefinitions>
+              <ClashingKeyLabel>Old</ClashingKeyLabel>
+              <ClashingKeyValue>
+                {formattedEnvironmentValue(this.props.existingValues[key]) ||
+                  emptyValue}
+              </ClashingKeyValue>
+              <ClashingKeyLabel>New</ClashingKeyLabel>
+              <ClashingKeyValue>
+                {formattedEnvironmentValue(value) || emptyValue}
+              </ClashingKeyValue>
+            </ClashingKeyDefinitions>
+          </ClashingKeyItem>
+        ))}
+      </PossibleClashingKeys>
+    );
+  }
+
   render() {
+    const clashingKeys = this.state.selectedEnvGroup
+      ? this.potentiallyOverriddenKeys(this.state.selectedEnvGroup.data)
+      : [];
     return (
       <StyledLoadEnvGroupModal>
         <CloseButton onClick={this.props.closeModal}>
@@ -109,16 +164,35 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
           {this.props.namespace}).
         </Subtitle>
 
-        <EnvGroupList>{this.renderEnvGroupList()}</EnvGroupList>
+        <GroupModalSections>
+          <SidebarSection $expanded={!this.state.selectedEnvGroup}>
+            <EnvGroupList>{this.renderEnvGroupList()}</EnvGroupList>
+          </SidebarSection>
+
+          {this.state.selectedEnvGroup && (
+            <SidebarSection>
+              <GroupEnvPreview>
+                {Object.entries(this.state.selectedEnvGroup.data)
+                  .map(
+                    ([key, value]) =>
+                      `${key}=${formattedEnvironmentValue(value)}`
+                  )
+                  .join("\n")}
+              </GroupEnvPreview>
+              {clashingKeys.length > 0 && (
+                <>
+                  <ClashingKeyRowDivider />
+                  {this.renderEnvGroupPreview(clashingKeys)}
+                </>
+              )}
+            </SidebarSection>
+          )}
+        </GroupModalSections>
 
         <SaveButton
           disabled={!this.state.selectedEnvGroup}
           text="Load Selected Env Group"
-          status={
-            !this.state.selectedEnvGroup
-              ? "No env group selected"
-              : "Existing env variables will be overidden"
-          }
+          status={this.saveButtonStatus(clashingKeys.length > 0)}
           onClick={this.onSubmit}
         />
       </StyledLoadEnvGroupModal>
@@ -128,6 +202,39 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
 
 LoadEnvGroupModal.contextType = Context;
 
+const SidebarSection = styled.section<{ $expanded?: boolean }>`
+  height: 100%;
+  overflow-y: auto;
+  ${(props) =>
+    props.$expanded &&
+    css`
+      grid-column: span 2;
+    `}
+`;
+
+const GroupEnvPreview = styled.pre`
+  font-family: monospace;
+  margin: 0 0 10px 0;
+  white-space: pre-line;
+  word-break: break-word;
+  user-select: text;
+`;
+
+const ClashingKeyExplanation = styled.div`
+  padding: 10px 15px;
+`;
+
+const ClashIconWrapper = styled.div`
+  padding: 10px;
+  background: #3d4048;
+  display: flex;
+  align-items: center;
+`;
+
+const ClashIcon = styled.i`
+  font-size: 18px;
+`;
+
 const Placeholder = styled.div`
   width: 100%;
   height: 150px;
@@ -168,13 +275,65 @@ const EnvGroupRow = styled.div<{ lastItem?: boolean; isSelected: boolean }>`
   }
 `;
 
-const EnvGroupList = styled.div`
+const GroupModalSections = styled.div`
   margin-top: 20px;
+  width: 100%;
+  height: 100%;
+  display: grid;
+  gap: 10px;
+  grid-template-columns: 1fr 1fr;
+  max-height: 365px;
+`;
+
+const PossibleClashingKeys = styled.ul`
+  appearance: none;
+  color: #aaaabb;
+  margin: 0;
+  padding-inline-start: 0;
+  list-style: none;
+  > *:not(:last-child) {
+    margin-bottom: 8px;
+  }
+`;
+
+const ClashingKeyItem = styled.li`
+  overflow: hidden;
+  border: 1px solid #292c31;
+  border-radius: 5px;
+`;
+
+const ClashingKeyRowDivider = styled.hr`
+  margin: 16px 0;
+  border: 1px solid #27292f;
+`;
+
+const ClashingKeyDefinitions = styled.div`
+  grid-template-columns: min-content auto;
+  padding: 5px 0;
+  column-gap: 6px;
+  display: grid;
+`;
+
+const ClashingKeyLabel = styled.p`
+  margin: 0px;
+  font-weight: bold;
+  padding: 5px 10px;
+  white-space: nowrap;
+`;
+
+const ClashingKeyValue = styled.p`
+  margin: 0px;
+  display: flex;
+  padding: 0;
+  align-items: center;
+  word-break: break-word;
+`;
+
+const EnvGroupList = styled.div`
   width: 100%;
   border-radius: 3px;
   background: #ffffff11;
   border: 1px solid #ffffff44;
-  max-height: 160px;
   overflow-y: auto;
 `;
 
@@ -185,11 +344,17 @@ const Subtitle = styled.div`
   color: #aaaabb;
 `;
 
+const ClashingKeyTop = styled.div`
+  background: #2e3035;
+  display: flex;
+  align-items: stretch;
+`;
+
 const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -162,7 +162,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -267,7 +267,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 46 - 7
dashboard/src/main/home/navbar/Navbar.tsx

@@ -5,19 +5,24 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import Feedback from "./Feedback";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { Select, MenuItem } from "@material-ui/core";
+import { AuthContext } from "shared/auth/AuthContext";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   logOut: () => void;
   currentView: string;
 };
 
 type StateType = {
   showDropdown: boolean;
+  currentPolicy: string;
 };
 
-export default class Navbar extends Component<PropsType, StateType> {
+class Navbar extends Component<PropsType, StateType> {
   state = {
     showDropdown: false,
+    currentPolicy: "admin",
   };
 
   renderSettingsDropdown = () => {
@@ -31,9 +36,19 @@ export default class Navbar extends Component<PropsType, StateType> {
             <DropdownLabel>
               {this.context.user && this.context.user.email}
             </DropdownLabel>
-            <LogOutButton onClick={this.props.logOut}>
+            <UserDropdownButton
+              onClick={() =>
+                this.context.setCurrentModal("AccountSettingsModal", {})
+              }
+            >
+              <SettingsIcon>
+                <i className="material-icons">settings</i>
+              </SettingsIcon>
+              Account Settings
+            </UserDropdownButton>
+            <UserDropdownButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
-            </LogOutButton>
+            </UserDropdownButton>
           </Dropdown>
         </>
       );
@@ -56,8 +71,7 @@ export default class Navbar extends Component<PropsType, StateType> {
             this.setState({ showDropdown: !this.state.showDropdown })
           }
         >
-          <I className="material-icons-outlined">account_circle</I>
-          {this.context.user?.email}
+          <I className="material-icons">account_circle</I>
           {this.renderSettingsDropdown()}
         </NavButton>
       </StyledNavbar>
@@ -67,10 +81,35 @@ export default class Navbar extends Component<PropsType, StateType> {
 
 Navbar.contextType = Context;
 
+export default withAuth(Navbar);
+
+const SettingsIcon = styled.div`
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 15px;
+    top: 11px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 0;
+  }
+`;
+
 const I = styled.i`
   margin-right: 7px;
 `;
 
+const PolicySelector = styled(Select)`
+  height: 30px;
+  width: 100px;
+  margin-right: 15px;
+  color: white !important;
+`;
+
 const CloseOverlay = styled.div`
   position: fixed;
   width: 100vw;
@@ -81,7 +120,7 @@ const CloseOverlay = styled.div`
   cursor: default;
 `;
 
-const LogOutButton = styled.button`
+const UserDropdownButton = styled.button`
   padding: 13px;
   height: 40px;
   font-size: 13px;

+ 421 - 237
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -1,4 +1,10 @@
-import React, { Component } from "react";
+import React, {
+  Component,
+  useState,
+  useEffect,
+  useContext,
+  useMemo,
+} from "react";
 import styled from "styled-components";
 
 import { InviteType } from "shared/types";
@@ -10,244 +16,438 @@ import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
 
-type PropsType = {};
+type Props = {};
 
-type StateType = {
-  loading: boolean;
-  invites: InviteType[];
+export type Collaborator = {
+  id: string;
+  user_id: string;
+  project_id: string;
   email: string;
-  invalidEmail: boolean;
-  isHTTPS: boolean;
+  kind: string;
 };
 
-const dummyInvites = [];
-
-export default class InviteList extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    invites: [] as InviteType[],
-    email: "",
-    invalidEmail: false,
-    isHTTPS: window.location.protocol === "https:",
+const InvitePage: React.FunctionComponent<Props> = ({}) => {
+  const { currentProject, setCurrentModal, user } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [invites, setInvites] = useState<Array<InviteType>>([]);
+  const [email, setEmail] = useState("");
+  const [role, setRole] = useState("developer");
+  const [roleList, setRoleList] = useState([]);
+  const [isInvalidEmail, setIsInvalidEmail] = useState(false);
+  const [isHTTPS] = useState(() => window.location.protocol === "https:");
+
+  useEffect(() => {
+    api
+      .getAvailableRoles("<token>", {}, { project_id: currentProject.id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setRole("developer");
+      });
+
+    getData();
+  }, [currentProject]);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
   };
 
-  componentDidMount() {
-    this.getInviteData();
-  }
-
-  getInviteData = () => {
-    let { currentProject } = this.context;
-
-    this.setState({ loading: true });
-    api
-      .getInvites(
+  const getData = async () => {
+    setIsLoading(true);
+    let invites = [];
+    try {
+      const response = await api.getInvites(
         "<token>",
         {},
         {
           id: currentProject.id,
         }
-      )
-      .then((res) => this.setState({ invites: res.data, loading: false }))
-      .catch((err) => console.log(err));
+      );
+      invites = response.data.filter((i: InviteType) => !i.accepted);
+    } catch (err) {
+      console.log(err);
+    }
+    let collaborators: any = [];
+    try {
+      const response = await api.getCollaborators(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      );
+      collaborators = parseCollaboratorsResponse(response.data);
+    } catch (err) {
+      console.log(err);
+    }
+    setInvites([...invites, ...collaborators]);
+    setIsLoading(false);
   };
 
-  validateEmail = () => {
-    var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    if (regex.test(this.state.email.toLowerCase())) {
-      this.setState({ invalidEmail: false });
-      this.createInvite();
-    } else {
-      this.setState({ invalidEmail: true });
-    }
+  const parseCollaboratorsResponse = (
+    collaborators: Array<Collaborator>
+  ): Array<InviteType> => {
+    return (
+      collaborators
+        // Parse role id to number
+        .map((c) => ({ ...c, id: Number(c.id) }))
+        // Sort them so the owner will be first allways
+        .sort((curr, prev) => curr.id - prev.id)
+        // Remove the owner from list
+        .slice(1)
+        // Parse the remainings to InviteType
+        .map((c) => ({
+          email: c.email,
+          expired: false,
+          id: Number(c.user_id),
+          kind: c.kind,
+          accepted: true,
+          token: "",
+        }))
+    );
   };
 
-  createInvite = () => {
-    let { currentProject } = this.context;
+  const createInvite = () => {
     api
-      .createInvite(
-        "<token>",
-        { email: this.state.email },
-        { id: currentProject.id }
-      )
-      .then((_) => {
-        this.getInviteData();
-        this.setState({ email: "" });
+      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
+      .then(() => {
+        getData();
+        setEmail("");
       })
       .catch((err) => console.log(err));
   };
 
-  deleteInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const deleteInvite = (inviteId: number) => {
     api
       .deleteInvite(
         "<token>",
         {},
         {
           id: currentProject.id,
-          invId: this.state.invites[index].id,
+          invId: inviteId,
         }
       )
-      .then(this.getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
-  replaceInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const replaceInvite = (
+    inviteEmail: string,
+    inviteId: number,
+    kind: string
+  ) => {
     api
       .createInvite(
         "<token>",
-        { email: this.state.invites[index].email },
+        { email: inviteEmail, kind },
         { id: currentProject.id }
       )
-      .then((_) =>
+      .then(() =>
         api.deleteInvite(
           "<token>",
           {},
           {
             id: currentProject.id,
-            invId: this.state.invites[index].id,
+            invId: inviteId,
           }
         )
       )
-      .then(this.getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
-  getInviteUrl = (index: number) => {
-    let { currentProject } = this.context;
-    return `${this.state.isHTTPS ? "https://" : ""}${
-      window.location.host
-    }/api/projects/${currentProject.id}/invites/${
-      this.state.invites[index].token
-    }`;
+  const validateEmail = () => {
+    const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+    if (!regex.test(email.toLowerCase())) {
+      setIsInvalidEmail(true);
+      return;
+    }
+
+    setIsInvalidEmail(false);
+    createInvite();
   };
 
-  renderInvitations = () => {
-    let { currentProject } = this.context;
-    if (this.state.loading) {
-      return <Loading />;
-    } else {
-      var invContent: any[] = [];
-      var collabList: any[] = [];
-      this.state.invites.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
-      this.state.invites.sort((a: any, b: any) =>
-        a.accepted > b.accepted ? 1 : -1
+  const openEditModal = (user: any) => {
+    if (setCurrentModal) {
+      setCurrentModal("EditInviteOrCollaboratorModal", {
+        user,
+        isInvite: user.status !== "accepted",
+        refetchCallerData: getData,
+      });
+    }
+  };
+
+  const removeCollaborator = (user_id: number) => {
+    try {
+      api.removeCollaborator(
+        "<token>",
+        {},
+        { project_id: currentProject.id, user_id }
       );
-      for (let i = 0; i < this.state.invites.length; i++) {
-        if (this.state.invites[i].accepted) {
-          collabList.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}></LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton invis={true}>Remove</CopyButton>
-              </Td>
-            </Tr>
+      getData();
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
+  const columns = useMemo<
+    Column<{
+      email: string;
+      id: number;
+      status: string;
+      invite_link: string;
+      kind: string;
+    }>[]
+  >(
+    () => [
+      {
+        Header: "User",
+        accessor: "email",
+      },
+      {
+        Header: "Role",
+        accessor: "kind",
+        Cell: ({ row }) => {
+          return <Role>{row.values.kind || "Admin"}</Role>;
+        },
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+        Cell: ({ row }) => {
+          return (
+            <Status status={row.values.status}>{row.values.status}</Status>
           );
-        } else if (this.state.invites[i].expired) {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  Link Expired.
-                  <NewLinkButton onClick={() => this.replaceInvite(i)}>
-                    <u>Generate a new link</u>
-                  </NewLinkButton>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        Header: "",
+        accessor: "invite_link",
+        Cell: ({ row }) => {
+          if (row.values.status === "expired") {
+            return (
+              <NewLinkButton
+                onClick={() =>
+                  replaceInvite(
+                    row.values.email,
+                    row.values.id,
+                    row.values.kind
+                  )
+                }
+              >
+                <u>Generate a new link</u>
+              </NewLinkButton>
+            );
+          }
+          if (row.values.status === "accepted") {
+            return "";
+          }
+
+          return (
+            <>
+              <CopyToClipboard as={Url} text={row.values.invite_link}>
+                <span>{row.values.invite_link}</span>
+                <i className="material-icons-outlined">content_copy</i>
+              </CopyToClipboard>
+            </>
           );
-        } else {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  <ShareLink
-                    disabled={true}
-                    type="string"
-                    value={`${this.state.isHTTPS ? "https://" : ""}${
-                      window.location.host
-                    }/api/projects/${currentProject.id}/invites/${
-                      this.state.invites[i].token
-                    }`}
-                    placeholder="Unable to retrieve link"
-                  />
-                  <CopyToClipboard
-                    as={CopyButton}
-                    text={this.getInviteUrl(i)}
-                    onError={() => console.log("Couldn't copy to clipboard")}
-                  >
-                    Copy Link
-                  </CopyToClipboard>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        id: "edit_action",
+        Cell: ({ row }: any) => {
+          return <></>;
+        },
+      },
+      {
+        id: "remove_invite_action",
+        Cell: ({ row }: any) => {
+          if (row.values.status === "accepted") {
+            return (
+              <Flex>
+                <SettingsButton
+                  invis={row.original.currentUser}
+                  onClick={() => openEditModal(row.original)}
+                >
+                  <i className="material-icons">more_vert</i>
+                </SettingsButton>
+                <DeleteButton
+                  invis={row.original.currentUser}
+                  onClick={() => removeCollaborator(row.original.id)}
+                >
+                  <i className="material-icons">delete</i>
+                </DeleteButton>
+              </Flex>
+            );
+          }
+          return (
+            <Flex>
+              <SettingsButton
+                invis={row.original.currentUser}
+                onClick={() => openEditModal(row.original)}
+              >
+                <i className="material-icons">more_vert</i>
+              </SettingsButton>
+              <DeleteButton
+                invis={row.original.currentUser}
+                onClick={() => deleteInvite(row.original.id)}
+              >
+                <i className="material-icons">delete</i>
+              </DeleteButton>
+            </Flex>
           );
+        },
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    const inviteList = [...invites];
+    inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
+    inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
+    const buildInviteLink = (token: string) => `
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
+      currentProject.id
+    }/invites/${token}
+    `;
+
+    const mappedInviteList = inviteList.map(
+      ({ accepted, expired, token, ...rest }) => {
+        const currentUser: boolean = user.email === rest.email;
+        if (accepted) {
+          return {
+            status: "accepted",
+            invite_link: buildInviteLink(token),
+            currentUser,
+            ...rest,
+          };
+        }
+
+        if (!accepted && expired) {
+          return {
+            status: "expired",
+            invite_link: buildInviteLink(token),
+            currentUser,
+            ...rest,
+          };
         }
+
+        return {
+          status: "pending",
+          invite_link: buildInviteLink(token),
+          currentUser,
+          ...rest,
+        };
       }
+    );
 
-      return (
-        <>
-          <Heading>Invites & Collaborators</Heading>
-          <Helper>Manage pending invites and view collaborators.</Helper>
-          {invContent.length > 0 || collabList.length > 0 ? (
-            <Table>
-              <tbody>
-                {invContent}
-                {collabList}
-              </tbody>
-            </Table>
-          ) : (
-            <Placeholder>
-              This project currently has no invites or collaborators.
-            </Placeholder>
-          )}
-        </>
-      );
-    }
-  };
+    return mappedInviteList || [];
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
-  render() {
-    return (
-      <>
-        <Heading isAtTop={true}>Share Project</Heading>
-        <Helper>Generate a project invite for another admin user.</Helper>
-        <DarkMatter />
+  return (
+    <>
+      <Heading isAtTop={true}>Share Project</Heading>
+      <Helper>Generate a project invite for another user.</Helper>
+      <InputRowWrapper>
         <InputRow
-          value={this.state.email}
+          value={email}
           type="text"
-          setValue={(x: string) => this.setState({ email: x })}
-          width="calc(100%)"
+          setValue={(newEmail: string) => setEmail(newEmail)}
+          width="100%"
           placeholder="ex: mrp@getporter.dev"
         />
-        <ButtonWrapper>
-          <InviteButton disabled={false} onClick={() => this.validateEmail()}>
-            Create Invite
-          </InviteButton>
-          {this.state.invalidEmail && (
-            <Invalid>Invalid email address. Please try again.</Invalid>
-          )}
-        </ButtonWrapper>
-        {this.renderInvitations()}
-      </>
-    );
+      </InputRowWrapper>
+      <Helper>Specify a role for this user.</Helper>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={role}
+          setSelected={setRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
+      <ButtonWrapper>
+        <InviteButton disabled={false} onClick={() => validateEmail()}>
+          Create Invite
+        </InviteButton>
+        {isInvalidEmail && (
+          <Invalid>Invalid email address. Please try again.</Invalid>
+        )}
+      </ButtonWrapper>
+
+      <Heading>Invites & Collaborators</Heading>
+      <Helper>Manage pending invites and view collaborators.</Helper>
+      {isLoading && <Loading height={"30%"} />}
+      {data?.length && !isLoading ? (
+        <Table
+          columns={columns}
+          data={data}
+          disableHover={true}
+          isLoading={false}
+          disableGlobalFilter={true}
+        />
+      ) : (
+        !isLoading && (
+          <Placeholder>
+            This project currently has no invites or collaborators.
+          </Placeholder>
+        )
+      )}
+    </>
+  );
+};
+
+export default InvitePage;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
   }
-}
 
-InviteList.contextType = Context;
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
 
 const Placeholder = styled.div`
   width: 100%;
@@ -267,9 +467,8 @@ const ButtonWrapper = styled.div`
   align-items: center;
 `;
 
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -10px;
+const InputRowWrapper = styled.div`
+  width: 40%;
 `;
 
 const CopyButton = styled.div`
@@ -278,16 +477,17 @@ const CopyButton = styled.div`
   color: #ffffff;
   font-weight: 400;
   font-size: 13px;
-  margin-left: 12px;
+  margin: 8px 0 8px 12px;
   float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
   width: 120px;
-  padding-top: 7px;
-  padding-bottom: 6px;
   cursor: pointer;
+  height: 30px;
   border-radius: 5px;
   border: 1px solid #ffffff20;
   background-color: #ffffff10;
-  text-align: center;
   overflow: hidden;
   transition: all 0.1s ease-out;
   :hover {
@@ -299,6 +499,9 @@ const CopyButton = styled.div`
 const NewLinkButton = styled(CopyButton)`
   border: none;
   width: auto;
+  float: none;
+  display: block;
+  margin: unset;
   background-color: transparent;
   :hover {
     border: none;
@@ -336,67 +539,28 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   margin-bottom: 10px;
 `;
 
-const Rower = styled.div`
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
   display: flex;
-  flex-direction: row;
   align-items: center;
-`;
-
-const ShareLink = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 60%;
-  color: #74a5f7;
-  margin-left: -10px;
-  padding: 5px 10px;
-  height: 30px;
-  text-overflow: ellipsis;
-  border-radius: 3px;
-  ::placeholder,
-  ::-webkit-input-placeholder {
-    color: #fa0a26;
-    font-weight: 600;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
   }
-`;
-
-const Table = styled.table`
-  width: 100%;
-  border-spacing: 0px;
-  border: 1px solid #ffffff55;
-  margin-top: 22px;
-  border-radius: 5px;
-  background: #ffffff11;
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-`;
 
-const Td = styled.td`
-  white-space: nowrap;
-  padding: 6px 0px;
-  border-top: ${(props: { isTop: boolean }) =>
-    props.isTop ? "none" : "1px solid #ffffff55"};
-  &:last-child {
-    padding-right: 16px;
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
-`;
-
-const Tr = styled.tr``;
-
-const MailTd = styled(Td)`
-  padding: 0 12px;
-  max-width: 186px;
-  min-width: 186px;
-  overflow: hidden;
-  color: #aaaabb;
-  text-overflow: ellipsis;
-`;
 
-const LinkTd = styled(Td)`
-  width: calc(100% - 40px);
-  padding-left: 40px;
+  :hover {
+    cursor: pointer;
+  }
 `;
 
 const Invalid = styled.div`
@@ -405,3 +569,23 @@ const Invalid = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
 `;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;

+ 24 - 10
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -3,37 +3,49 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 
-import InviteList from "./InviteList";
+import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {};
+type PropsType = WithAuthProps & {};
 
 type StateType = {
   projectName: string;
   currentTab: string;
+  tabOptions: { value: string; label: string }[];
 };
 
-const tabOptions = [
-  { value: "manage-access", label: "Manage Access" },
-  { value: "additional-settings", label: "Additional Settings" },
-];
-
-export default class ProjectSettings extends Component<PropsType, StateType> {
+class ProjectSettings extends Component<PropsType, StateType> {
   state = {
     projectName: "",
     currentTab: "manage-access",
+    tabOptions: [] as { value: string; label: string }[],
   };
 
   componentDidMount() {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
+    const tabOptions = [];
+    tabOptions.push({ value: "manage-access", label: "Manage Access" });
+    if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
+      tabOptions.push({
+        value: "additional-settings",
+        label: "Additional Settings",
+      });
+    }
+
+    this.setState({ tabOptions });
   }
 
   renderTabContents = () => {
+    if (!this.props.isAuthorized("settings", "", ["get", "delete"])) {
+      return <InvitePage />;
+    }
+
     if (this.state.currentTab === "manage-access") {
-      return <InviteList />;
+      return <InvitePage />;
     } else {
       return (
         <>
@@ -85,7 +97,7 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
         <TabRegion
           currentTab={this.state.currentTab}
           setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          options={tabOptions}
+          options={this.state.tabOptions}
         >
           {this.renderTabContents()}
         </TabRegion>
@@ -96,6 +108,8 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
 
 ProjectSettings.contextType = Context;
 
+export default withAuth(ProjectSettings);
+
 const Warning = styled.div`
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>

+ 34 - 22
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -16,14 +16,16 @@ import ProjectSectionContainer from "./ProjectSectionContainer";
 import loading from "assets/loading.gif";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered, pushQueryParams } from "shared/routing";
-
-type PropsType = RouteComponentProps & {
-  forceSidebar: boolean;
-  setWelcome: (x: boolean) => void;
-  currentView: string;
-  forceRefreshClusters: boolean;
-  setRefreshClusters: (x: boolean) => void;
-};
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    forceSidebar: boolean;
+    setWelcome: (x: boolean) => void;
+    currentView: string;
+    forceRefreshClusters: boolean;
+    setRefreshClusters: (x: boolean) => void;
+  };
 
 type StateType = {
   showSidebar: boolean;
@@ -231,18 +233,28 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
-          <NavButton
-            selected={currentView === "integrations"}
-            onClick={() =>
-              pushFiltered(this.props, "/integrations", ["project_id"])
-            }
-          >
-            <Img src={integrations} />
-            Integrations
-          </NavButton>
-          {this.context.currentProject.roles.filter((obj: any) => {
-            return obj.user_id === this.context.user.userId;
-          })[0].kind === "admin" && (
+
+          {this.props.isAuthorized("integrations", "", [
+            "get",
+            "create",
+            "update",
+            "delete",
+          ]) && (
+            <NavButton
+              selected={currentView === "integrations"}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations", ["project_id"])
+              }
+            >
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
+          {this.props.isAuthorized("settings", "", [
+            "get",
+            "update",
+            "delete",
+          ]) && (
             <NavButton
               onClick={() =>
                 pushFiltered(this.props, "/project-settings", ["project_id"])
@@ -313,7 +325,7 @@ class Sidebar extends Component<PropsType, StateType> {
 
 Sidebar.contextType = Context;
 
-export default withRouter(Sidebar);
+export default withRouter(withAuth(Sidebar));
 
 const BranchPad = styled.div`
   width: 20px;
@@ -572,7 +584,7 @@ const Tooltip = styled.div`
   flex: 1;
   color: white;
   font-size: 12px;
-  font-family: "Assistant", sans-serif;
+  font-family: Work Sans, sans-serif;
   outline: 1px solid #ffffff55;
   opacity: 0;
   animation: faded-in 0.2s 0.15s;

+ 3 - 1
dashboard/src/shared/Context.tsx

@@ -86,9 +86,11 @@ class ContextProvider extends Component<PropsType, StateType> {
     },
     currentProject: null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
-      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
       if (currentProject) {
         localStorage.setItem("currentProject", currentProject.id.toString());
+        pushQueryParams(this.props, {
+          project_id: currentProject.id.toString(),
+        });
       } else {
         localStorage.removeItem("currentProject");
       }

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

@@ -150,6 +150,7 @@ const createGKE = baseApi<
 const createInvite = baseApi<
   {
     email: string;
+    kind: string;
   },
   {
     id: number;
@@ -458,6 +459,19 @@ const getClusterNodes = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/nodes`;
 });
 
+const getClusterNode = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    nodeName: string;
+  }
+>(
+  "GET",
+  (pathParams) =>
+    `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/node/${pathParams.nodeName}`
+);
+
 const getGitRepoList = baseApi<
   {},
   {
@@ -719,6 +733,10 @@ const linkGithubProject = baseApi<
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
+const getGithubAccess = baseApi<{}, {}>("GET", () => {
+  return `/api/integrations/github-app/access`;
+});
+
 const logInUser = baseApi<{
   email: string;
   password: string;
@@ -904,6 +922,43 @@ const stopJob = baseApi<
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
 });
 
+const getAvailableRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/roles`
+);
+
+const updateInvite = baseApi<
+  { kind: string },
+  { project_id: number; invite_id: number }
+>(
+  "POST",
+  ({ project_id, invite_id }) =>
+    `/api/projects/${project_id}/invites/${invite_id}`
+);
+
+const getCollaborators = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/collaborators`
+);
+
+const updateCollaborator = baseApi<
+  { kind: string },
+  { project_id: number; user_id: number }
+>(
+  "POST",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const removeCollaborator = baseApi<{}, { project_id: number; user_id: number }>(
+  "DELETE",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const getPolicyDocument = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/policy`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -951,6 +1006,7 @@ export default {
   getClusters,
   getCluster,
   getClusterNodes,
+  getClusterNode,
   getConfigMap,
   getGitRepoList,
   getGitRepos,
@@ -982,6 +1038,7 @@ export default {
   getTemplates,
   getUser,
   linkGithubProject,
+  getGithubAccess,
   listConfigMaps,
   logInUser,
   logOutUser,
@@ -995,4 +1052,10 @@ export default {
   upgradeChartValues,
   deleteJob,
   stopJob,
+  updateInvite,
+  getAvailableRoles,
+  getCollaborators,
+  updateCollaborator,
+  removeCollaborator,
+  getPolicyDocument,
 };

+ 51 - 0
dashboard/src/shared/auth/AuthContext.tsx

@@ -0,0 +1,51 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { POLICY_HIERARCHY_TREE, populatePolicy } from "./authorization-helpers";
+import { PolicyDocType } from "./types";
+
+type AuthContext = {
+  currentPolicy: PolicyDocType;
+};
+
+export const AuthContext = React.createContext<AuthContext>({} as AuthContext);
+
+const AuthProvider: React.FC = ({ children }) => {
+  const { user, currentProject } = useContext(Context);
+  const [currentPolicy, setCurrentPolicy] = useState(null);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!user) {
+      setCurrentPolicy(null);
+    } else {
+      api
+        .getPolicyDocument("<token>", {}, { project_id: currentProject?.id })
+        .then((res) => {
+          if (!isSubscribed) {
+            return;
+          }
+          const currentPolicy = res.data[0];
+          setCurrentPolicy(
+            populatePolicy(
+              currentPolicy,
+              POLICY_HIERARCHY_TREE,
+              currentPolicy.scope,
+              currentPolicy.verbs
+            )
+          );
+        });
+    }
+    return () => {
+      isSubscribed = false;
+    };
+  }, [user, currentProject?.id]);
+
+  return (
+    <AuthContext.Provider value={{ currentPolicy }}>
+      {children}
+    </AuthContext.Provider>
+  );
+};
+
+export default AuthProvider;

+ 54 - 0
dashboard/src/shared/auth/AuthorizationHoc.tsx

@@ -0,0 +1,54 @@
+import React, { useCallback } from "react";
+import { useContext } from "react";
+import { AuthContext } from "./AuthContext";
+import { isAuthorized } from "./authorization-helpers";
+import { ScopeType, Verbs } from "./types";
+
+export const GuardedComponent = <ComponentProps extends object>(
+  scope: ScopeType,
+  resource: string,
+  verb: Verbs | Array<Verbs>
+) => (Component: any) => (props: ComponentProps) => {
+  const authContext = useContext(AuthContext);
+
+  if (isAuthorized(authContext.currentPolicy, scope, resource, verb)) {
+    return <Component {...props} />;
+  }
+
+  return null;
+};
+
+export type WithAuthProps = {
+  isAuthorized: (
+    scope: ScopeType,
+    resource: string | Array<string>,
+    verb: Verbs | Array<Verbs>
+  ) => boolean;
+};
+
+export function withAuth<P>(
+  // Then we need to type the incoming component.
+  // This creates a union type of whatever the component
+  // already accepts AND our extraInfo prop
+  WrappedComponent: React.ComponentType<P & WithAuthProps>
+) {
+  const displayName = `withAuth(${
+    WrappedComponent.displayName || WrappedComponent.name
+  })`;
+
+  const C = (props: P) => {
+    const authContext = useContext(AuthContext);
+
+    const isAuth = useCallback(
+      (scope: ScopeType, resource: string, verb: Verbs | Array<Verbs>) =>
+        isAuthorized(authContext.currentPolicy, scope, resource, verb),
+      [authContext.currentPolicy]
+    );
+    // At this point, the props being passed in are the original props the component expects.
+    return <WrappedComponent {...props} isAuthorized={isAuth} />;
+  };
+
+  C.displayName = displayName;
+  C.WrappedComponent = WrappedComponent;
+  return C;
+}

+ 62 - 0
dashboard/src/shared/auth/RouteGuard.tsx

@@ -0,0 +1,62 @@
+import UnauthorizedPage from "components/UnauthorizedPage";
+import React, { useMemo, useContext } from "react";
+import { Redirect, Route, RouteProps } from "react-router";
+import { AuthContext } from "./AuthContext";
+import { isAuthorized } from "./authorization-helpers";
+import { ScopeType, Verbs } from "./types";
+
+import Loading from "components/Loading";
+
+type GuardedRouteProps = {
+  scope: ScopeType;
+  resource: string;
+  verb: Verbs | Array<Verbs>;
+};
+
+const GuardedRoute: React.FC<RouteProps & GuardedRouteProps> = ({
+  component: Component,
+  scope,
+  resource,
+  verb,
+  children,
+  ...rest
+}) => {
+  const { currentPolicy } = useContext(AuthContext);
+  const auth = useMemo(() => {
+    return isAuthorized(currentPolicy, scope, resource, verb);
+  }, [currentPolicy, scope, resource, verb]);
+
+  const render = (props: any) => {
+    if (!currentPolicy) {
+      return <div> Loading </div>;
+    }
+    if (auth) {
+      return children || <Component {...props} />;
+    }
+    return <UnauthorizedPage />;
+  };
+
+  return <Route {...rest} render={render} />;
+};
+
+export const fakeGuardedRoute = <ComponentProps extends object>(
+  scope: string,
+  resource: string,
+  verb: Verbs | Array<Verbs>
+) => (Component: any) => (props: ComponentProps) => {
+  const { currentPolicy } = useContext(AuthContext);
+  const auth = useMemo(() => {
+    return isAuthorized(currentPolicy, scope, resource, verb);
+  }, [currentPolicy, scope, resource, verb]);
+
+  if (!currentPolicy) {
+    return <Loading />;
+  }
+  if (auth) {
+    return <Component {...props} />;
+  }
+
+  return <UnauthorizedPage />;
+};
+
+export default GuardedRoute;

+ 127 - 0
dashboard/src/shared/auth/authorization-helpers.ts

@@ -0,0 +1,127 @@
+import { HIERARCHY_TREE, PolicyDocType, ScopeType, Verbs } from "./types";
+
+export const ADMIN_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list", "create", "update", "delete"],
+};
+
+export const DEV_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list", "create", "update", "delete"],
+  resources: [],
+  children: {
+    settings: {
+      scope: "settings",
+      verbs: ["get", "list"],
+      resources: [],
+    },
+  },
+};
+
+export const VIEWER_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list"],
+  resources: [],
+  children: {
+    integrations: {
+      scope: "integrations",
+      verbs: [],
+      resources: [],
+    },
+    settings: {
+      scope: "settings",
+      verbs: [],
+      resources: [],
+    },
+  },
+};
+
+export const POLICY_HIERARCHY_TREE: HIERARCHY_TREE = {
+  project: {
+    cluster: {
+      namespace: {
+        application: {},
+        job: {},
+        env_group: {},
+      },
+    },
+    settings: {},
+    integrations: {},
+  },
+};
+
+export const isAuthorized = (
+  policy: PolicyDocType,
+  scope: string,
+  resource: string | Array<string>,
+  verb: Verbs | Array<Verbs>
+): boolean => {
+  if (!policy) {
+    return false;
+  }
+
+  if (policy?.scope === scope) {
+    let isResourceIncluded = false;
+    if (policy.resources.length === 0) {
+      isResourceIncluded = true;
+    } else if (typeof resource === "string") {
+      isResourceIncluded = policy.resources.includes(resource);
+    } else {
+      isResourceIncluded = resource.every((r) => policy.resources.includes(r));
+    }
+
+    return (
+      isResourceIncluded &&
+      (typeof verb === "string"
+        ? policy.verbs.includes(verb)
+        : verb.every((v) => policy.verbs.includes(v)))
+    );
+  } else {
+    const isValid =
+      policy?.children &&
+      Object.values(policy.children).reduce((prev, currentPol) => {
+        if (isAuthorized(currentPol, scope, resource, verb)) {
+          return true;
+        } else {
+          return prev || false;
+        }
+      }, false);
+
+    return !!isValid;
+  }
+};
+
+export const populatePolicy = (
+  currPolicy: PolicyDocType,
+  tree: HIERARCHY_TREE,
+  currScope: ScopeType,
+  parentVerbs: Array<Verbs>
+) => {
+  const currTree = tree[currScope];
+  const treeKeys = Object.keys(currTree) as Array<ScopeType>;
+
+  currPolicy.children = currPolicy?.children || {};
+  currPolicy.resources = currPolicy?.resources || [];
+
+  for (const child of treeKeys) {
+    let childPolicy = currPolicy?.children && currPolicy?.children[child];
+    if (!childPolicy) {
+      childPolicy = {
+        scope: child,
+        verbs: parentVerbs,
+        resources: [],
+        children: {},
+      };
+    }
+    childPolicy.resources = childPolicy?.resources || [];
+    childPolicy.children = childPolicy?.children || {};
+    currPolicy.children[child] = populatePolicy(
+      childPolicy,
+      currTree,
+      childPolicy.scope,
+      currPolicy.verbs
+    );
+  }
+
+  return currPolicy;
+};

+ 28 - 0
dashboard/src/shared/auth/types.ts

@@ -0,0 +1,28 @@
+export type ScopeType =
+  | "project"
+  | "cluster"
+  | "settings"
+  | "namespace"
+  | "application"
+  | "env_group"
+  | "job"
+  | "integrations";
+
+export type Verbs = "get" | "list" | "create" | "update" | "delete";
+
+export interface PolicyDocType {
+  scope: ScopeType;
+  verbs: Array<Verbs>;
+  resources?: string[];
+  children?: Partial<Record<ScopeType, PolicyDocType>>;
+}
+
+export enum ScopeTypeEnum {
+  PROJECT = "project",
+  CLUSTER = "cluster",
+  SETTINGS = "settings",
+  NAMESPACE = "namespace",
+  APPLICATION = "application",
+}
+
+export type HIERARCHY_TREE = { [key: string]: any };

+ 21 - 0
dashboard/src/shared/auth/useAuth.ts

@@ -0,0 +1,21 @@
+import { useCallback, useContext } from "react";
+import { AuthContext } from "./AuthContext";
+import { isAuthorized } from "./authorization-helpers";
+import { ScopeType, Verbs } from "./types";
+
+const useAuth = () => {
+  const authContext = useContext(AuthContext);
+
+  const isAuth = useCallback(
+    (
+      scope: ScopeType,
+      resource: string | string[],
+      verb: Verbs | Array<Verbs>
+    ) => isAuthorized(authContext.currentPolicy, scope, resource, verb),
+    [authContext.currentPolicy]
+  );
+
+  return [isAuth];
+};
+
+export default useAuth;

+ 62 - 0
dashboard/src/shared/hardcodedNameDict.tsx

@@ -0,0 +1,62 @@
+const hardcodedNames: { [key: string]: string } = {
+  agones: "Agones System",
+  docker: "Docker",
+  "https-issuer": "HTTPS Issuer",
+  metabase: "Metabase",
+  mongodb: "MongoDB",
+  datadog: "Datadog",
+  "wallarm-ingress": "Wallarm Ingress",
+  mysql: "MySQL",
+  postgresql: "PostgreSQL",
+  redis: "Redis",
+  ubuntu: "Ubuntu",
+  web: "Web Service",
+  worker: "Worker",
+  job: "Job",
+  "cert-manager": "Cert Manager",
+  elasticsearch: "Elasticsearch",
+  prometheus: "Prometheus",
+  rabbitmq: "RabbitMQ",
+  logdna: "LogDNA",
+  "tailscale-relay": "Tailscale",
+};
+
+const hardcodedIcons: { [key: string]: string } = {
+  "https-issuer":
+    "https://cdn4.iconfinder.com/data/icons/macster-2/100/https__-512.png",
+  metabase:
+    "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
+  mongodb:
+    "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
+  datadog:
+    "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
+  wallarm:
+    "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
+  agones:
+    "https://avatars.githubusercontent.com/u/36940055?v=4",
+  mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
+  postgresql:
+    "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",
+  redis:
+    "https://cdn4.iconfinder.com/data/icons/redis-2/1451/Untitled-2-512.png",
+  ubuntu: "Ubuntu",
+  web:
+    "https://user-images.githubusercontent.com/65516095/111255214-07d3da80-85ed-11eb-99e2-fddcbdb99bdb.png",
+  worker:
+    "https://user-images.githubusercontent.com/65516095/111255250-1b7f4100-85ed-11eb-8bd1-7b17be3e0e06.png",
+  job:
+    "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png",
+  "cert-manager":
+    "https://raw.githubusercontent.com/jetstack/cert-manager/master/logo/logo.png",
+  elasticsearch:
+    "https://ria.gallerycdn.vsassets.io/extensions/ria/elastic/0.13.3/1530754501320/Microsoft.VisualStudio.Services.Icons.Default",
+  prometheus:
+    "https://raw.githubusercontent.com/prometheus/prometheus.github.io/master/assets/prometheus_logo-cb55bb5c346.png",
+  rabbitmq:
+    "https://bitnami.com/assets/stacks/rabbitmq/img/rabbitmq-stack-220x234.png",
+  logdna:
+    "https://user-images.githubusercontent.com/65516095/118185526-a2447480-b40a-11eb-9bdb-82aa0a306f26.png",
+  "tailscale-relay": "Tailscale",
+};
+
+export { hardcodedNames, hardcodedIcons };

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -135,6 +135,7 @@ export interface FormElement {
   placeholder?: string;
   value?: any;
   settings?: {
+    docs?: string;
     default?: number | string | boolean;
     options?: any[];
     omitUnitFromValue?: boolean;

+ 1 - 0
dashboard/webpack.config.js

@@ -55,6 +55,7 @@ module.exports = () => {
     },
     devServer: {
       historyApiFallback: true,
+      disableHostCheck: true,
     },
     plugins: [
       new HtmlWebpackPlugin({

+ 2 - 0
docker-compose.dev.yaml

@@ -24,6 +24,8 @@ services:
       - ./cmd:/porter/cmd
       - ./internal:/porter/internal
       - ./server:/porter/server
+      - ./api:/porter/api
+      - ./docker/kubeconfig.yaml:/porter/kubeconfig.yaml
   postgres:
     image: postgres:latest
     container_name: postgres

+ 1 - 0
docker/Dockerfile

@@ -11,6 +11,7 @@ COPY go.mod go.sum ./
 COPY /cmd ./cmd
 COPY /internal ./internal
 COPY /server ./server
+COPY /api ./api
 
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
     go mod download

+ 31 - 0
docs/guides/authorization-and-team-management.md

@@ -0,0 +1,31 @@
+Porter supports setting basic authorization permissions via for other members in a Porter project. At the moment, there are 3 roles that can be assigned in a Porter project:
+
+- **Admin:** read/write access to all resources, ability to delete the project and manage team members.
+- **Developer:** read/write access to applications, jobs, environment groups, cluster data, and integrations.
+- **Viewer:** read access to applications, jobs, environment groups, and cluster data.
+
+# Adding Collaborators
+
+To add a new collaborator to a Porter project, you must be logged in with an **Admin** role. As an admin, you will see a **Settings** tab in the sidebar. Navigate to **Settings** and input the email of the user you would like to add. This will generate an invitation link for the user, which expires in 24 hours. The user will get an email to join the Porter project, but if the email is not delivered, you can copy the invite link and send it to them directly.
+
+![image](https://user-images.githubusercontent.com/23369263/125147098-b00f3100-e0ff-11eb-8579-cc28c1a0badc.png)
+
+> 🚧
+> 
+> If the user does not have a Porter account, they will be asked to register. After registering, if they are not automatically added to the project, the user should **click the invite link again**.  
+
+# Changing Collaborator Permissions
+
+To change an invite or collaborator role, you must be logged in with an **Admin** role. As an admin, you will se a **Settings** tab in the sidebar. Navigate to **Settings** and lookup on the table the invite/collaborator that you want to change it's role, then click the icon with three dots on the row. This will open a pop up that will allow you to select the new role for that invite/collaborator.
+
+You will note that the user that created the project will not be displayed on the table, and you cannot change your own permissions.
+
+![image](https://user-images.githubusercontent.com/23369263/125147141-ea78ce00-e0ff-11eb-9e8b-a3f126874d12.png)
+
+![image](https://user-images.githubusercontent.com/23369263/125147157-0aa88d00-e100-11eb-8d78-1cf34397cd26.png)
+
+# Removing Collaborators
+
+To remove an invite or a collaborator, you must be logged in with an **Admin** role. As an admin, you will se a **Settings** tab in the sidebar. Navigate to **Settings** and lookup on the table the invite/collaborator that you want to remove then click the trash icon to remove the user from the project or delete the invite.
+
+![image](https://user-images.githubusercontent.com/23369263/125147206-3d528580-e100-11eb-9a58-51885ab8b298.png)

+ 23 - 0
docs/guides/linking-github-account.md

@@ -0,0 +1,23 @@
+# Configuring Github Access
+
+> 🚧
+>
+> **Note:** Porter currently uses a Github OAuth App to authenticate and gain access to repositories. This mechanism will be phased out over the next few weeks to transition to the authentication method below. After this, all old applications will still work as intended but new applications will need to be authenticated through a GitHub App.
+
+Porter uses a GitHub App to authorize and gain access to your GitHub repositories. In order to be able to deploy applications through GitHub repositories, you must first authorize the Porter GitHub App to have access to them.
+
+## Step 1: Authorize the Porter Application
+
+On your home page, click select "Account Settings" through the dropdown on the top right and click "connect your GitHub account" in the popup that opens:
+
+![image](https://user-images.githubusercontent.com/25856165/125105942-0acb6d00-e0ad-11eb-8254-6660d390daea.png)
+
+Then, follow the GitHub steps to authorize the application.
+
+## Step 2: Install App in your repositories
+
+Once the Porter Github App is authorized, you can see a list of accounts and organization the Porter has access to through the same popup:
+
+![image](https://user-images.githubusercontent.com/25856165/125106692-ee7c0000-e0ad-11eb-9c79-44714f898aa5.png)
+
+You can install the app into more repositories by clicking on "Install Porter in more repositories". Note that if you are part of an organization, Porter will show you access to every repository that the app is installed into regardless of who it was installed by. So, if your organization does not grant you access to install applications, having an admin install the application into the appropriate repositories is sufficient.

+ 5 - 2
internal/auth/sessionstore/sessionstore.go

@@ -120,8 +120,11 @@ func NewStore(repo *repository.Repository, conf config.ServerConf) (*PGStore, er
 	dbStore := &PGStore{
 		Codecs: securecookie.CodecsFromPairs(keyPairs...),
 		Options: &sessions.Options{
-			Path:   "/",
-			MaxAge: 86400 * 30,
+			Path:     "/",
+			MaxAge:   86400 * 30,
+			Secure:   true,
+			HttpOnly: true,
+			SameSite: http.SameSiteLaxMode,
 		},
 		Repo: repo,
 	}

+ 14 - 4
internal/config/config.go

@@ -41,6 +41,11 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
+	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
+	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
+	GithubAppName          string `env:"GITHUB_APP_NAME"`
+	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
+
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
 	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`
@@ -52,10 +57,15 @@ type ServerConf struct {
 	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
 	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
 
-	DOClientID          string `env:"DO_CLIENT_ID"`
-	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
-	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`
-	SegmentClientKey    string `env:"SEGMENT_CLIENT_KEY"`
+	DOClientID                 string `env:"DO_CLIENT_ID"`
+	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
+	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
+	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
+	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
+
+	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
+	IngressCluster     string `env:"INGRESS_CLUSTER"`
+	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 8 - 6
internal/forms/helper_test.go

@@ -200,12 +200,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 2 - 0
internal/forms/invite.go

@@ -11,6 +11,7 @@ import (
 // invite to a project
 type CreateInvite struct {
 	Email     string `json:"email" form:"required"`
+	Kind      string `json:"kind" form:"required"`
 	ProjectID uint   `form:"required"`
 }
 
@@ -21,6 +22,7 @@ func (ci *CreateInvite) ToInvite() (*models.Invite, error) {
 
 	return &models.Invite{
 		Email:     ci.Email,
+		Kind:      ci.Kind,
 		Expiry:    &expiry,
 		ProjectID: ci.ProjectID,
 		Token:     oauth.CreateRandomState(),

+ 3 - 16
internal/forms/project.go

@@ -3,7 +3,6 @@ package forms
 import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
 )
 
 // WriteProjectForm is a generic form for write operations to the Project model
@@ -24,20 +23,8 @@ func (cpf *CreateProjectForm) ToProject(_ repository.ProjectRepository) (*models
 	}, nil
 }
 
-// CreateProjectRoleForm represents the accepted values for creating a project
+// UpdateProjectRoleForm represents the accepted values for updating a project
 // role
-type CreateProjectRoleForm struct {
-	WriteProjectForm
-	ID    uint          `json:"project_id" form:"required"`
-	Roles []models.Role `json:"roles"`
-}
-
-// ToProject converts the form to a gorm project model
-func (cprf *CreateProjectRoleForm) ToProject(_ repository.ProjectRepository) (*models.Project, error) {
-	return &models.Project{
-		Model: gorm.Model{
-			ID: cprf.ID,
-		},
-		Roles: cprf.Roles,
-	}, nil
+type UpdateProjectRoleForm struct {
+	Kind string `json:"kind"`
 }

+ 1 - 0
internal/helm/agent.go

@@ -168,6 +168,7 @@ func (a *Agent) InstallChart(
 
 	cmd.ReleaseName = conf.Name
 	cmd.Namespace = conf.Namespace
+	cmd.Timeout = 300
 
 	if err := checkIfInstallable(conf.Chart); err != nil {
 		return nil, err

+ 28 - 20
internal/helm/postrenderer.go

@@ -216,21 +216,12 @@ func (d *DockerSecretsPostRenderer) getRegistriesToLink(renderedManifests *bytes
 
 		// read the image url
 		for _, image := range images {
-			named, err := reference.ParseNormalizedNamed(image)
+			regName, err := getRegNameFromImageRef(image)
 
 			if err != nil {
 				continue
 			}
 
-			domain := reference.Domain(named)
-			path := reference.Path(named)
-
-			regName := domain
-
-			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
-				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
-			}
-
 			// check if the integration is native to the cluster/registry combination
 			isNative := d.isRegistryNative(regName)
 
@@ -353,21 +344,12 @@ func (d *DockerSecretsPostRenderer) updatePodSpecs(secrets map[string]string) {
 				continue
 			}
 
-			named, err := reference.ParseNormalizedNamed(image)
+			regName, err := getRegNameFromImageRef(image)
 
 			if err != nil {
 				continue
 			}
 
-			domain := reference.Domain(named)
-			path := reference.Path(named)
-
-			regName := domain
-
-			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
-				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
-			}
-
 			if secretName, ok := secrets[regName]; ok && secretName != "" {
 				imagePullSecrets = append(imagePullSecrets, map[string]interface{}{
 					"name": secretName,
@@ -504,3 +486,29 @@ func getNestedResource(res resource, keys ...string) resource {
 
 	return curr
 }
+
+func getRegNameFromImageRef(image string) (string, error) {
+	named, err := reference.ParseNormalizedNamed(image)
+
+	if err != nil {
+		return "", err
+	}
+
+	domain := reference.Domain(named)
+	path := reference.Path(named)
+
+	var regName string
+
+	// if registry is dockerhub, leave the image name as-is
+	if strings.Contains(domain, "docker.io") {
+		regName = "index.docker.io/" + path
+	} else {
+		regName = domain
+
+		if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
+			regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
+		}
+	}
+
+	return regName, nil
+}

+ 218 - 3
internal/kubernetes/agent.go

@@ -3,10 +3,13 @@ package kubernetes
 import (
 	"bufio"
 	"bytes"
+	"compress/gzip"
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
@@ -45,6 +48,8 @@ import (
 	"k8s.io/client-go/tools/remotecommand"
 
 	"github.com/porter-dev/porter/internal/config"
+
+	rspb "helm.sh/helm/v3/pkg/release"
 )
 
 // Agent is a Kubernetes agent for performing operations that interact with the
@@ -560,7 +565,7 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 
 	stopper := make(chan struct{})
 	errorchan := make(chan error)
-	defer close(errorchan)
+	defer close(stopper)
 
 	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
 		UpdateFunc: func(oldObj, newObj interface{}) {
@@ -604,8 +609,204 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 		// listens for websocket closing handshake
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
-				defer conn.Close()
-				defer close(stopper)
+				conn.Close()
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
+}
+
+var b64 = base64.StdEncoding
+
+var magicGzip = []byte{0x1f, 0x8b, 0x08}
+
+func decodeRelease(data string) (*rspb.Release, error) {
+	// base64 decode string
+	b, err := b64.DecodeString(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// For backwards compatibility with releases that were stored before
+	// compression was introduced we skip decompression if the
+	// gzip magic header is not found
+	if bytes.Equal(b[0:3], magicGzip) {
+		r, err := gzip.NewReader(bytes.NewReader(b))
+		if err != nil {
+			return nil, err
+		}
+		defer r.Close()
+		b2, err := ioutil.ReadAll(r)
+		if err != nil {
+			return nil, err
+		}
+		b = b2
+	}
+
+	var rls rspb.Release
+	// unmarshal release object bytes
+	if err := json.Unmarshal(b, &rls); err != nil {
+		return nil, err
+	}
+	return &rls, nil
+}
+
+func contains(s []string, str string) bool {
+	for _, v := range s {
+		if v == str {
+			return true
+		}
+	}
+
+	return false
+}
+
+func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Release, bool, error) {
+	if secret.Type != "helm.sh/release.v1" {
+		return nil, true, nil
+	}
+
+	releaseData, ok := secret.Data["release"]
+
+	if !ok {
+		return nil, true, fmt.Errorf("release field not found")
+	}
+
+	helm_object, err := decodeRelease(string(releaseData))
+
+	if err != nil {
+		return nil, true, err
+	}
+
+	if len(chartList) > 0 && !contains(chartList, helm_object.Name) {
+		return nil, true, nil
+	}
+
+	return helm_object, false, nil
+}
+
+func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
+	tweakListOptionsFunc := func(options *metav1.ListOptions) {
+		options.LabelSelector = selectors
+	}
+
+	factory := informers.NewSharedInformerFactoryWithOptions(
+		a.Clientset,
+		0,
+		informers.WithTweakListOptions(tweakListOptionsFunc),
+	)
+
+	informer := factory.Core().V1().Secrets().Informer()
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(stopper)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			secretObj, ok := newObj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		AddFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "ADD",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "DELETE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				conn.Close()
 				errorchan <- nil
 				return
 			}
@@ -633,6 +834,7 @@ func (a *Agent) ProvisionECR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -643,6 +845,7 @@ func (a *Agent) ProvisionECR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -668,6 +871,7 @@ func (a *Agent) ProvisionEKS(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -678,6 +882,7 @@ func (a *Agent) ProvisionEKS(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -703,6 +908,7 @@ func (a *Agent) ProvisionGCR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -713,6 +919,7 @@ func (a *Agent) ProvisionGCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
@@ -735,6 +942,7 @@ func (a *Agent) ProvisionGKE(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -745,6 +953,7 @@ func (a *Agent) ProvisionGKE(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
@@ -771,6 +980,7 @@ func (a *Agent) ProvisionDOCR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -796,6 +1006,7 @@ func (a *Agent) ProvisionDOCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		DO: &do.Conf{
 			DOToken: tok,
@@ -821,6 +1032,7 @@ func (a *Agent) ProvisionDOKS(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -847,6 +1059,7 @@ func (a *Agent) ProvisionDOKS(
 		Postgres:            pgConf,
 		LastApplied:         infra.LastApplied,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		DO: &do.Conf{
 			DOToken: tok,
 		},
@@ -868,6 +1081,7 @@ func (a *Agent) ProvisionTest(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 
@@ -879,6 +1093,7 @@ func (a *Agent) ProvisionTest(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 	}
 
 	return a.provision(prov, infra, repo)

+ 3 - 3
internal/kubernetes/config.go

@@ -85,7 +85,7 @@ func GetAgentInClusterConfig() (*Agent, error) {
 		return nil, err
 	}
 
-	restClientGetter := newRESTClientGetterFromInClusterConfig(conf)
+	restClientGetter := NewRESTClientGetterFromInClusterConfig(conf)
 	clientset, err := kubernetes.NewForConfig(conf)
 
 	return &Agent{restClientGetter, clientset}, nil
@@ -386,9 +386,9 @@ func (conf *OutOfClusterConfig) setTokenCache(token string, expiry time.Time) er
 	return err
 }
 
-// newRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
+// NewRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
 // default values set from the *rest.Config
-func newRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
+func NewRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
 	cfs := genericclioptions.NewConfigFlags(false)
 
 	cfs.ClusterName = &conf.ServerName

+ 31 - 0
internal/kubernetes/local/kubeconfig.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 
+	k8s "k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -58,6 +59,36 @@ func GetKubeconfigFromHost(kubeconfigPath string, contexts []string) ([]byte, er
 	return clientcmd.Write(strippedRawConf)
 }
 
+// GetSelfAgentFromFileConfig reads a kubeconfig from a local file and generates an
+// Agent from that kubeconfig
+func GetSelfAgentFromFileConfig(kubeconfigPath string) (*kubernetes.Agent, error) {
+	configBytes, err := GetKubeconfigFromHost(kubeconfigPath, []string{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	cmdConf, err := clientcmd.NewClientConfigFromBytes(configBytes)
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restClientGetter := kubernetes.NewRESTClientGetterFromInClusterConfig(restConf)
+	clientset, err := k8s.NewForConfig(restConf)
+
+	return &kubernetes.Agent{
+		RESTClientGetter: restClientGetter,
+		Clientset:        clientset,
+	}, nil
+}
+
 // ResolveKubeconfigPath finds the path to a kubeconfig, first searching for the
 // passed string, then in the home directory, then as an env variable.
 func ResolveKubeconfigPath(kubeconfigPath string) (string, error) {

+ 9 - 6
internal/kubernetes/nodes/helpers.go

@@ -112,11 +112,14 @@ func DescribeNodeResource(nodeNonTerminatedPodsList *corev1.PodList, node *corev
 	}
 
 	return &NodeUsage{
-		fractionCpuReqs,
-		fractionCpuLimits,
-		fractionMemoryReqs,
-		fractionMemoryLimits,
-		fractionEphemeralStorageReqs,
-		fractionEphemeralStorageLimits,
+		cpuReqs:                        cpuReqs.String(),
+		memoryReqs:                     memoryReqs.String(),
+		ephemeralStorageReqs:           ephemeralstorageReqs.String(),
+		fractionCpuReqs:                fractionCpuReqs,
+		fractionCpuLimits:              fractionCpuLimits,
+		fractionMemoryReqs:             fractionMemoryReqs,
+		fractionMemoryLimits:           fractionMemoryLimits,
+		fractionEphemeralStorageReqs:   fractionEphemeralStorageReqs,
+		fractionEphemeralStorageLimits: fractionEphemeralStorageLimits,
 	}
 }

+ 37 - 6
internal/kubernetes/nodes/nodes.go

@@ -11,6 +11,9 @@ import (
 )
 
 type NodeUsage struct {
+	cpuReqs                        string
+	memoryReqs                     string
+	ephemeralStorageReqs           string
 	fractionCpuReqs                float64
 	fractionCpuLimits              float64
 	fractionMemoryReqs             float64
@@ -21,18 +24,26 @@ type NodeUsage struct {
 
 type NodeWithUsageData struct {
 	Name                           string             `json:"name"`
-	FractionCpuReqs                float64            `json:"cpu_reqs"`
-	FractionCpuLimits              float64            `json:"cpu_limits"`
-	FractionMemoryReqs             float64            `json:"memory_reqs"`
-	FractionMemoryLimits           float64            `json:"memory_limits"`
-	FractionEphemeralStorageReqs   float64            `json:"ephemeral_storage_reqs"`
-	FractionEphemeralStorageLimits float64            `json:"ephemeral_storage_limits"`
+	Labels                         map[string]string  `json:"labels"`
+	CpuReqs                        string             `json:"cpu_reqs"`
+	MemoryReqs                     string             `json:"memory_reqs"`
+	EphemeralStorageReqs           string             `json:"ephemeral_storage_reqs"`
+	FractionCpuReqs                float64            `json:"fraction_cpu_reqs"`
+	FractionCpuLimits              float64            `json:"fraction_cpu_limits"`
+	FractionMemoryReqs             float64            `json:"fraction_memory_reqs"`
+	FractionMemoryLimits           float64            `json:"fraction_memory_limits"`
+	FractionEphemeralStorageReqs   float64            `json:"fraction_ephemeral_storage_reqs"`
+	FractionEphemeralStorageLimits float64            `json:"fraction_ephemeral_storage_limits"`
 	Condition                      []v1.NodeCondition `json:"node_conditions"`
 }
 
 func (nu *NodeUsage) Externalize(node v1.Node) *NodeWithUsageData {
 	return &NodeWithUsageData{
 		Name:                           node.Name,
+		Labels:                         node.Labels,
+		CpuReqs:                        nu.cpuReqs,
+		MemoryReqs:                     nu.memoryReqs,
+		EphemeralStorageReqs:           nu.ephemeralStorageReqs,
 		FractionCpuReqs:                nu.fractionCpuReqs,
 		FractionCpuLimits:              nu.fractionCpuLimits,
 		FractionMemoryReqs:             nu.fractionMemoryReqs,
@@ -72,3 +83,23 @@ func getPodsForNode(clientset kubernetes.Interface, nodeName string) *v1.PodList
 
 	return podList
 }
+
+type NodeDetails struct {
+	NodeWithUsageData
+	AllocatableCpu    int64  `json:"allocatable_cpu"`
+	AllocatableMemory string `json:"allocatable_memory"`
+}
+
+func DescribeNode(clientset kubernetes.Interface, nodeName string) *NodeDetails {
+	node, _ := clientset.CoreV1().Nodes().Get(context.TODO(), nodeName, metav1.GetOptions{})
+
+	podList := getPodsForNode(clientset, node.Name)
+	nodeUsage := DescribeNodeResource(podList, node)
+	extNodeUsage := nodeUsage.Externalize(*node)
+
+	return &NodeDetails{
+		NodeWithUsageData: *extNodeUsage,
+		AllocatableCpu:    node.Status.Allocatable.Cpu().MilliValue(),
+		AllocatableMemory: node.Status.Allocatable.Memory().String(),
+	}
+}

+ 11 - 24
internal/kubernetes/provisioner/provisioner.go

@@ -45,6 +45,7 @@ type Conf struct {
 	Postgres            *config.DBConf
 	Operation           ProvisionerOperation
 	ProvisionerImageTag string
+	ImagePullSecret     string
 	LastApplied         []byte
 
 	// provider-specific configurations
@@ -290,6 +291,14 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 		env = conf.DOKS.AttachDOKSEnv(env)
 	}
 
+	imagePullSecrets := []v1.LocalObjectReference{}
+
+	if conf.ImagePullSecret != "" {
+		imagePullSecrets = append(imagePullSecrets, v1.LocalObjectReference{
+			Name: conf.ImagePullSecret,
+		})
+	}
+
 	return &batchv1.Job{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      conf.Name,
@@ -304,7 +313,8 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 					Labels: labels,
 				},
 				Spec: v1.PodSpec{
-					RestartPolicy: v1.RestartPolicyNever,
+					RestartPolicy:    v1.RestartPolicyNever,
+					ImagePullSecrets: imagePullSecrets,
 					Containers: []v1.Container{
 						{
 							Name:            "provisioner",
@@ -312,24 +322,6 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 							ImagePullPolicy: v1.PullAlways,
 							Args:            args,
 							Env:             env,
-							VolumeMounts: []v1.VolumeMount{
-								v1.VolumeMount{
-									MountPath: "/.terraform/plugin-cache",
-									Name:      "tf-cache",
-									ReadOnly:  true,
-								},
-							},
-						},
-					},
-					Volumes: []v1.Volume{
-						v1.Volume{
-							Name: "tf-cache",
-							VolumeSource: v1.VolumeSource{
-								PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{
-									ClaimName: "tf-cache-pvc",
-									ReadOnly:  true,
-								},
-							},
 						},
 					},
 				},
@@ -431,11 +423,6 @@ func (conf *Conf) addTFEnv(env []v1.EnvVar) []v1.EnvVar {
 		Value: "./terraform",
 	})
 
-	env = append(env, v1.EnvVar{
-		Name:  "TF_PLUGIN_CACHE_DIR",
-		Value: "/.terraform/plugin-cache",
-	})
-
 	env = append(env, v1.EnvVar{
 		Name:  "TF_PORTER_BACKEND",
 		Value: "postgres",

+ 33 - 0
internal/models/integrations/github_app.go

@@ -0,0 +1,33 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// GithubAppInstallation is an instance of the porter github app
+// we need to store account/installation id pairs in order to authenticate as the installation
+type GithubAppInstallation struct {
+	gorm.Model
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id" gorm:"unique"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+type GithubAppInstallationExternal struct {
+	ID uint `json:"id"`
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+func (r *GithubAppInstallation) Externalize() *GithubAppInstallationExternal {
+	return &GithubAppInstallationExternal{
+		ID:             r.ID,
+		AccountID:      r.AccountID,
+		InstallationID: r.InstallationID,
+	}
+}

+ 21 - 7
internal/models/integrations/oauth.go

@@ -14,10 +14,23 @@ const (
 	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 
+// SharedOAuthModel stores general fields needed for OAuth Integration
+type SharedOAuthModel struct {
+	// The ID issued to the client
+	ClientID []byte `json:"client-id"`
+
+	// The end-users's access token
+	AccessToken []byte `json:"access-token"`
+
+	// The end-user's refresh token
+	RefreshToken []byte `json:"refresh-token"`
+}
+
 // OAuthIntegration is an auth mechanism that uses oauth
 // https://tools.ietf.org/html/rfc6749
 type OAuthIntegration struct {
 	gorm.Model
+	SharedOAuthModel
 
 	// The name of the auth mechanism
 	Client OAuthIntegrationClient `json:"client"`
@@ -31,15 +44,16 @@ type OAuthIntegration struct {
 	// ------------------------------------------------------------------
 	// All fields encrypted before storage.
 	// ------------------------------------------------------------------
+}
 
-	// The ID issued to the client
-	ClientID []byte `json:"client-id"`
-
-	// The end-users's access token
-	AccessToken []byte `json:"access-token"`
+// GithubAppOAuthIntegration is the model used for storing github app oauth data
+// Unlike the above, this model is tied to a specific user, not a project
+type GithubAppOAuthIntegration struct {
+	gorm.Model
+	SharedOAuthModel
 
-	// The end-user's refresh token
-	RefreshToken []byte `json:"refresh-token"`
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
 }
 
 // OAuthIntegrationExternal is an OAuthIntegration to be shared over REST

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů