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

Merge branch 'master' of https://github.com/porter-dev/porter into main

sunguroku 5 лет назад
Родитель
Сommit
37e3de96df
53 измененных файлов с 3607 добавлено и 1116 удалено
  1. 1 0
      cmd/app/main.go
  2. 1 0
      cmd/migrate/main.go
  3. BIN
      dashboard/src/assets/loading-dots.gif
  4. 16 0
      dashboard/src/assets/settings.svg
  5. 91 0
      dashboard/src/components/values-form/CheckboxList.tsx
  6. 4 4
      dashboard/src/components/values-form/Heading.tsx
  7. 16 6
      dashboard/src/main/CurrentError.tsx
  8. 3 2
      dashboard/src/main/Login.tsx
  9. 11 3
      dashboard/src/main/Main.tsx
  10. 118 65
      dashboard/src/main/home/Home.tsx
  11. 3 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  12. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  13. 38 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  14. 95 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx
  15. 102 105
      dashboard/src/main/home/dashboard/Dashboard.tsx
  16. 42 2
      dashboard/src/main/home/integrations/IntegrationList.tsx
  17. 29 3
      dashboard/src/main/home/integrations/Integrations.tsx
  18. 0 277
      dashboard/src/main/home/modals/UpdateProjectModal.tsx
  19. 19 472
      dashboard/src/main/home/new-project/NewProject.tsx
  20. 388 0
      dashboard/src/main/home/project-settings/InviteList.tsx
  21. 177 0
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  22. 392 0
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  23. 101 0
      dashboard/src/main/home/provisioner/ExistingClusterSection.tsx
  24. 182 0
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  25. 69 0
      dashboard/src/main/home/provisioner/InfraStatuses.tsx
  26. 269 0
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  27. 99 67
      dashboard/src/main/home/provisioner/ProvisionerStatus.tsx
  28. 20 3
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  29. 2 0
      dashboard/src/main/home/sidebar/Drawer.tsx
  30. 5 31
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  31. 1 1
      dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx
  32. 40 31
      dashboard/src/main/home/sidebar/Sidebar.tsx
  33. 1 1
      dashboard/src/shared/Context.tsx
  34. 54 35
      dashboard/src/shared/api.tsx
  35. 66 0
      dashboard/src/shared/common.tsx
  36. 9 1
      dashboard/src/shared/types.tsx
  37. 28 0
      internal/forms/invite.go
  38. 48 0
      internal/models/invite.go
  39. 3 0
      internal/models/project.go
  40. 28 0
      internal/repository/gorm/helpers_test.go
  41. 98 0
      internal/repository/gorm/invite.go
  42. 100 0
      internal/repository/gorm/invite_test.go
  43. 1 0
      internal/repository/gorm/repository.go
  44. 15 0
      internal/repository/invite.go
  45. 124 0
      internal/repository/memory/invite.go
  46. 1 0
      internal/repository/memory/repository.go
  47. 2 1
      internal/repository/repository.go
  48. 239 0
      server/api/invite_handler.go
  49. 273 0
      server/api/invite_handler_test.go
  50. 11 1
      server/api/user_handler.go
  51. 4 0
      server/api/user_handler_test.go
  52. 124 0
      server/router/middleware/auth.go
  53. 43 0
      server/router/router.go

+ 1 - 0
cmd/app/main.go

@@ -57,6 +57,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
cmd/migrate/main.go

@@ -37,6 +37,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

BIN
dashboard/src/assets/loading-dots.gif


+ 16 - 0
dashboard/src/assets/settings.svg

@@ -0,0 +1,16 @@
+<svg width="28" height="30" viewBox="0 0 28 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g filter="url(#filter0_d)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M22.4023 13.5801C22.7599 13.7701 23.0359 14.0701 23.23 14.3701C23.6082 14.9901 23.5775 15.7501 23.2096 16.4201L22.4942 17.6201C22.1161 18.2601 21.411 18.6601 20.6854 18.6601C20.3277 18.6601 19.9291 18.5601 19.6021 18.3601C19.3364 18.1901 19.0298 18.1301 18.7028 18.1301C17.691 18.1301 16.8428 18.9601 16.8121 19.9501C16.8121 21.1001 15.8719 22.0001 14.6967 22.0001H13.3068C12.1214 22.0001 11.1812 21.1001 11.1812 19.9501C11.1607 18.9601 10.3125 18.1301 9.30076 18.1301C8.96351 18.1301 8.65693 18.1901 8.40144 18.3601C8.07441 18.5601 7.66563 18.6601 7.31816 18.6601C6.58235 18.6601 5.8772 18.2601 5.49908 17.6201L4.79393 16.4201C4.4158 15.7701 4.39536 14.9901 4.77349 14.3701C4.937 14.0701 5.24359 13.7701 5.59106 13.5801C5.8772 13.4401 6.06116 13.2101 6.23489 12.9401C6.74587 12.0801 6.43928 10.9501 5.57062 10.4401C4.55888 9.87012 4.23185 8.60012 4.81437 7.61012L5.49908 6.43012C6.09181 5.44012 7.35904 5.09012 8.381 5.67012C9.2701 6.15012 10.4249 5.83012 10.9461 4.98012C11.1096 4.70012 11.2016 4.40012 11.1812 4.10012C11.1607 3.71012 11.2731 3.34012 11.4673 3.04012C11.8454 2.42012 12.5301 2.02012 13.2762 2.00012H14.7171C15.4734 2.00012 16.1581 2.42012 16.5362 3.04012C16.7202 3.34012 16.8428 3.71012 16.8121 4.10012C16.7917 4.40012 16.8837 4.70012 17.0472 4.98012C17.5684 5.83012 18.7232 6.15012 19.6225 5.67012C20.6343 5.09012 21.9117 5.44012 22.4942 6.43012L23.1789 7.61012C23.7717 8.60012 23.4447 9.87012 22.4227 10.4401C21.554 10.9501 21.2474 12.0801 21.7686 12.9401C21.9322 13.2101 22.1161 13.4401 22.4023 13.5801ZM11.1096 12.0101C11.1096 13.5801 12.4075 14.8301 14.012 14.8301C15.6165 14.8301 16.8837 13.5801 16.8837 12.0101C16.8837 10.4401 15.6165 9.18012 14.012 9.18012C12.4075 9.18012 11.1096 10.4401 11.1096 12.0101Z" fill="white"/>
+</g>
+<defs>
+<filter id="filter0_d" x="-2" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
+<feOffset dy="4"/>
+<feGaussianBlur stdDeviation="2"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
+</filter>
+</defs>
+</svg>

+ 91 - 0
dashboard/src/components/values-form/CheckboxList.tsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  options: { disabled?: boolean, value: string, label: string }[],
+  selected: { value: string, label: string }[],
+  setSelected: (x: { value: string, label: string }[]) => void,
+};
+
+const CheckboxList = ({ 
+  label, options, selected, setSelected,
+}: PropsType) => {
+  let onSelectOption = (option: { value: string, label: string }) => {
+    if (!selected.includes(option)) {
+      selected.push(option);
+      setSelected(selected);
+    } else {
+      selected.splice(selected.indexOf(option), 1);
+      setSelected(selected);
+    }
+  }
+  
+  return (
+    <StyledCheckboxList>
+      {label && <Label>{label}</Label>}
+      {options.map((option: { value: string, label: string }, i: number) => {
+        return (
+          <CheckboxOption 
+            isLast={i === options.length - 1}
+            onClick={() => onSelectOption(option)}
+            key={i}
+          >
+            <Checkbox checked={selected.includes(option)}>
+              <i className="material-icons">done</i>
+            </Checkbox>
+            {option.label}
+          </CheckboxOption>
+        );
+      })}
+    </StyledCheckboxList>
+  );
+}
+export default CheckboxList;
+
+const Checkbox = styled.div`
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 15px 0px 1px;
+  border-radius: 3px;
+  background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : '#ffffff11'};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 12px;
+    padding-left: 0px;
+    display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
+  }
+`;
+
+const CheckboxOption = styled.div<{ isLast: boolean }>`
+  width: 100%;
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  cursor: pointer;
+  align-items: center;
+  border-bottom: ${props => props.isLast ? '' : '1px solid #ffffff22'};
+  font-size: 13px;
+
+  :hover {
+    background: #ffffff18;
+  }
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledCheckboxList = styled.div`
+  border-radius: 3px;
+  border: 1px solid #ffffff55;
+  padding: 0;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 4 - 4
dashboard/src/components/values-form/Heading.tsx

@@ -1,15 +1,15 @@
 import React from 'react';  
 import styled from 'styled-components';
 
-export default function Heading(props: { children: any }) {
-  return <StyledHeading>{props.children}</StyledHeading>;
+export default function Heading(props: { isAtTop?: boolean, children: any }) {
+  return <StyledHeading isAtTop={props.isAtTop}>{props.children}</StyledHeading>;
 }
 
-const StyledHeading = styled.div`
+const StyledHeading = styled.div<{ isAtTop: boolean }>`
   color: white;
   font-weight: 500;
   font-size: 16px;
-  margin-top: 30px;
+  margin-top: ${props => props.isAtTop ? '0': '30px'};
   margin-bottom: 5px;
   display: flex;
   align-items: center;

+ 16 - 6
dashboard/src/main/CurrentError.tsx

@@ -5,6 +5,7 @@ import close from '../assets/close.png';
 import { Context } from '../shared/Context';
 
 type PropsType = {
+  currentError: string,
 };
 
 type StateType = {
@@ -12,15 +13,24 @@ type StateType = {
 
 export default class CurrentError extends Component<PropsType, StateType> {
   state = {
-    expanded: false
+    expanded: false,
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (
+      prevProps.currentError !== this.props.currentError
+      && this.props.currentError === 'Provisioning failed. Check your credentials and try again.'
+    ) {
+      this.setState({ expanded: true });
+    }
   }
   
   render() {
-    if (this.context.currentError) {
+    if (this.props.currentError) {
       if (!this.state.expanded) {
         return (
           <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
-            <ErrorText>Error: {this.context.currentError}</ErrorText>
+            <ErrorText>Error: {this.props.currentError}</ErrorText>
             <CloseButton onClick={(e) => {
               this.context.setCurrentError(null);
               e.stopPropagation();
@@ -33,7 +43,7 @@ export default class CurrentError extends Component<PropsType, StateType> {
 
       return (
         <ExpandedError onClick={() => this.setState({ expanded: false })}>
-          Error: {this.context.currentError}
+          Error: {this.props.currentError}
           <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
             <CloseButtonImg src={close} />
           </CloseButtonAlt>
@@ -80,9 +90,9 @@ const ErrorText = styled.div`
 
 const StyledCurrentError = styled.div`
   position: fixed;
-  bottom: 20px;
+  bottom: 22px;
   width: 300px;
-  left: 17px;
+  left: 100px;
   padding: 15px;
   padding-right: 0px;
   font-family: 'Work Sans', sans-serif;

+ 3 - 2
dashboard/src/main/Login.tsx

@@ -43,7 +43,7 @@ export default class Login extends Component<PropsType, StateType> {
   handleLogin = (): void => {
     let { email, password } = this.state;
     let { authenticate } = this.props;
-    let { setCurrentError, setUser } = this.context;
+    let { setUser } = this.context;
 
     // Check for valid input
     if (!emailRegex.test(email)) {
@@ -55,8 +55,9 @@ export default class Login extends Component<PropsType, StateType> {
         password: password
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
+        console.log(res.data);
         setUser(res?.data?.id, res?.data?.email)
-        err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        err ? console.log(err) : authenticate();
       });
     }
   }

+ 11 - 3
dashboard/src/main/Main.tsx

@@ -32,7 +32,6 @@ export default class Main extends Component<PropsType, StateType> {
   componentDidMount() {
     let { setUser } = this.context;
     api.checkAuth('', {}, {}, (err: any, res: any) => {    
-      console.log(err)  
       if (err && err.response?.status == 403) {
         this.setState({ isLoggedIn: false, loading: false })
       }
@@ -56,6 +55,9 @@ export default class Main extends Component<PropsType, StateType> {
   }
 
   handleLogOut = () => {
+    // Clears local storage for proper rendering of clusters
+    localStorage.clear();
+
     this.context.clearContext();
     this.setState({ isLoggedIn: false, initialized: true });
   }
@@ -85,7 +87,13 @@ export default class Main extends Component<PropsType, StateType> {
 
         <Route path='/dashboard' render={() => {
           if (this.state.isLoggedIn && this.state.initialized) {
-            return <Home logOut={this.handleLogOut} />
+            return (
+              <Home 
+                currentProject={this.context.currentProject}
+                currentCluster={this.context.currentCluster} 
+                logOut={this.handleLogOut} 
+              />
+            );
           } else {
             return <Redirect to='/' />
           }
@@ -111,7 +119,7 @@ export default class Main extends Component<PropsType, StateType> {
         <BrowserRouter>
           {this.renderMain()}
         </BrowserRouter>
-        <CurrentError />
+        <CurrentError currentError={this.context.currentError} />
       </StyledMain>
     );
   }

+ 118 - 65
dashboard/src/main/home/Home.tsx

@@ -1,10 +1,13 @@
 import React, { Component } from 'react';
+import posthog from 'posthog-js';
 import styled from 'styled-components';
 import ReactModal from 'react-modal';
+import * as FullStory from '@fullstory/browser';
 
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
-import { InfraType } from '../../shared/types';
+import { ClusterType, ProjectType } from '../../shared/types';
+import { includesCompletedInfraSet } from '../../shared/common';
 
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
@@ -12,26 +15,26 @@ import ClusterDashboard from './cluster-dashboard/ClusterDashboard';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
 import Integrations from "./integrations/Integrations";
-import UpdateProjectModal from './modals/UpdateProjectModal';
 import UpdateClusterModal from './modals/UpdateClusterModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 import IntegrationsModal from './modals/IntegrationsModal';
 import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
 import NewProject from './new-project/NewProject';
 import Navbar from './navbar/Navbar';
-import Provisioner from './new-project/Provisioner';
-import posthog from 'posthog-js';
-import * as FullStory from '@fullstory/browser';
+import ProvisionerStatus from './provisioner/ProvisionerStatus';
+import ProjectSettings from './project-settings/ProjectSettings';
+import ConfirmOverlay from '../../components/ConfirmOverlay';
 
 type PropsType = {
-  logOut: () => void
+  logOut: () => void,
+  currentProject: ProjectType,
+  currentCluster: ClusterType,
 };
 
 type StateType = {
   forceSidebar: boolean,
   showWelcome: boolean,
   currentView: string,
-  viewData: any[],
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
   // Track last project id for refreshing clusters on project change
@@ -39,54 +42,49 @@ type StateType = {
   sidebarReady: boolean, // Fixes error where ~1/3 times reloading to provisioner fails
 };
 
+// TODO: Handle cluster connected but with some failed infras (no successful set)
 export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
     showWelcome: false,
     currentView: 'dashboard',
     prevProjectId: null as number | null,
-    viewData: null as any,
     forceRefreshClusters: false,
     sidebarReady: false,
   }
 
-  // Possibly consolidate into context (w/ ProjectSection + NewProject)
+  initializeView = () => {
+    let { currentCluster } = this.context;
+    let { currentProject } = this.props;
+    // Check if current project is provisioning
+    api.getInfra('<token>', {}, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+      console.log(currentCluster);
+      if (!currentCluster && !includesCompletedInfraSet(res.data)) {
+        this.setState({ currentView: 'provisioner', sidebarReady: true, });
+      } else {
+        this.setState({ currentView: 'dashboard', sidebarReady: true });
+      }
+    });
+  }
+
   getProjects = () => {
-    let { user, currentProject, projects, setProjects } = this.context;
+    let { user, setProjects } = this.context;
+    let { currentProject } = this.props;
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
       if (err) {
         console.log(err);
       } else if (res.data) {
-        setProjects(res.data);
-        if (res.data.length > 0 && !currentProject) {
+        if (res.data.length === 0) {
+          this.setState({ currentView: 'new-project', sidebarReady: true, });
+        } else if (res.data.length > 0 && !currentProject) {
+          setProjects(res.data);
           this.context.setCurrentProject(res.data[0]);
 
-          // Check if current project is provisioning
-          api.getInfra('<token>', {}, { project_id: res.data[0].id }, (err: any, res: any) => {
-            if (err) {
-              console.log(err);
-            } else if (res.data) {
-
-              let viewData = [] as any[]
-              // TODO: separately handle non meta-provisioning case
-              res.data.forEach((el: InfraType) => {
-                if (el.status === 'creating') {
-                  viewData.push({
-                    infra_id: el.id,
-                    kind: el.kind,
-                  });
-                }
-              });
-              
-              if (viewData.length > 0) {
-                this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, });
-              } else {
-                this.setState({ sidebarReady: true });
-              }
-            }
-          });
-        } else if (res.data.length === 0) {
-          this.setState({ currentView: 'new-project', sidebarReady: true, });
+          this.initializeView();
         }
       }
     });
@@ -101,20 +99,15 @@ export default class Home extends Component<PropsType, StateType> {
     })
 
     FullStory.identify(user.email)
-
     this.getProjects();
   }
 
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props && this.context.currentProject) {
-
-      // Set view to dashboard on project change
-      if (this.state.prevProjectId && this.state.prevProjectId !== this.context.currentProject.id) {
-        this.setState({
-          prevProjectId: this.context.currentProject.id,
-          currentView: 'dashboard'
-        });
-      }
+    if (
+      prevProps.currentProject !== this.props.currentProject
+      || prevProps.currentCluster !== this.props.currentCluster
+    ) {
+      this.initializeView();
     }
   }
 
@@ -158,22 +151,28 @@ export default class Home extends Component<PropsType, StateType> {
     } else if (currentView === 'dashboard') {
       return (
         <DashboardWrapper>
-          <Dashboard setCurrentView={(x: string) => this.setState({ currentView: x })} />
+          <Dashboard 
+            setCurrentView={(x: string) => this.setState({ currentView: x })}
+            projectId={this.context.currentProject?.id}
+          />
         </DashboardWrapper>
       );
     } else if (currentView === 'integrations') {
       return <Integrations />;
     } else if (currentView === 'new-project') {
       return (
-        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x, viewData: data })} />
+        <NewProject setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} />
       );
     } else if (currentView === 'provisioner') {
       return (
-        <Provisioner 
+        <ProvisionerStatus
           setCurrentView={(x: string) => this.setState({ currentView: x })}
-          viewData={this.state.viewData}
         />
       );
+    } else if (currentView === 'project-settings') {
+      return (
+        <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
+      )
     }
 
     return (
@@ -183,11 +182,11 @@ export default class Home extends Component<PropsType, StateType> {
     );
   }
 
-  setCurrentView = (x: string, viewData?: any) => {
-    if (!viewData) {
-      this.setState({ currentView: x });
+  setCurrentView = (x: string) => {
+    if (x === 'dashboard') {
+      this.initializeView();
     } else {
-      this.setState({ currentView: x, viewData });
+      this.setState({ currentView: x });
     }
   }
 
@@ -197,7 +196,7 @@ export default class Home extends Component<PropsType, StateType> {
       // Force sidebar closed on first provision
       if (this.state.currentView === 'provisioner' && this.state.forceSidebar) {
         this.setState({ forceSidebar: false });
-      } else if (this.state.sidebarReady) {
+      } else {
         return (
           <Sidebar
             forceSidebar={this.state.forceSidebar}
@@ -212,6 +211,61 @@ export default class Home extends Component<PropsType, StateType> {
     }
   }
 
+  projectOverlayCall = () => {
+    let { user, setProjects } = this.context;
+    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else if (res.data) {
+        setProjects(res.data);
+        if (res.data.length > 0) {
+          this.context.setCurrentProject(res.data[0]);
+        } else {
+          this.context.currentModalData.setCurrentView('new-project');
+        }
+        this.context.setCurrentModal(null, null);
+      }
+    });
+  }
+
+  handleDelete = () => {
+    let { setCurrentModal, currentProject } = this.context;
+    api.deleteProject('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        // console.log(err)
+      } else {
+        this.projectOverlayCall();
+      }
+    });
+
+    // Loop through and delete infra of all clusters we've provisioned
+    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        res.data.forEach((cluster: ClusterType) => {
+
+          // Handle destroying infra we've provisioned
+          if (cluster.infra_id) {
+            console.log('destroying provisioned infra...', cluster.infra_id);
+            api.destroyCluster('<token>', { eks_name: cluster.name }, { 
+              project_id: currentProject.id,
+              infra_id: cluster.infra_id,
+            }, (err: any, res: any) => {
+              if (err) {
+                console.log(err)
+              } else {
+                console.log('destroyed provisioned infra:', cluster.infra_id);
+              }
+            });
+          }
+        });
+      }
+    });
+    setCurrentModal(null, null)
+    this.setState({ currentView: 'dashboard' });
+  }
+
   render() {
     let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
@@ -224,14 +278,6 @@ export default class Home extends Component<PropsType, StateType> {
         >
           <ClusterInstructionsModal />
         </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'UpdateProjectModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={ProjectModalStyles}
-          ariaHideApp={false}
-        >
-          <UpdateProjectModal />
-        </ReactModal>
         <ReactModal
           isOpen={currentModal === 'UpdateClusterModal'}
           onRequestClose={() => setCurrentModal(null, null)}
@@ -268,6 +314,13 @@ export default class Home extends Component<PropsType, StateType> {
           />
           {this.renderContents()}
         </ViewWrapper>
+
+        <ConfirmOverlay
+          show={currentModal === 'UpdateProjectModal'}
+          message={(currentProject) ? `Are you sure you want to delete ${currentProject.name}?` : ''}
+          onYes={this.handleDelete}
+          onNo={() => setCurrentModal(null, null)}
+        />
       </StyledHome>
     );
   }

+ 3 - 1
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -34,7 +34,9 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
     localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
     if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState({ namespace: 'default', sortType: 'Newest', currentChart: null });
+      this.setState({ namespace: 'default', sortType: (
+        localStorage.getItem("SortType") ? localStorage.getItem('SortType') : 'Newest'
+      ), currentChart: null });
     }
   }
 

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

@@ -59,7 +59,7 @@ export default class ChartList extends Component<PropsType, StateType> {
         } else if (this.props.sortType == "Oldest") {
           charts.sort((a: any, b: any) => (Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)) ? 1 : -1);
         } else if (this.props.sortType == "Alphabetical") {
-          charts.sort((a: any, b: any) => (a.name > b.name) ? 1: -1);
+          charts.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
         }
         this.setState({ charts }, () => {
           this.setState({ loading: false, error: false });

+ 38 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -6,6 +6,7 @@ import { ResourceType, NodeType, EdgeType, ChartType } from '../../../../../shar
 import Node from './Node';
 import Edge from './Edge';
 import InfoPanel from './InfoPanel';
+import ZoomPanel from './ZoomPanel';
 import SelectRegion from './SelectRegion';
 
 const zoomConstant = 0.01;
@@ -41,6 +42,7 @@ type StateType = {
   preventBgDrag: boolean, // Prevent bg drag when moving selected with mouse down
   relocateAllowed: boolean, // Suppress movement of selected when drawing select region
   scale: number,
+  btnZooming: boolean,
   showKindLabels: boolean,
   isExpanded: boolean,
   currentNode: NodeType | null,
@@ -73,6 +75,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     preventBgDrag: false,
     relocateAllowed: false,
     scale: 0.5,
+    btnZooming: false,
     showKindLabels: true,
     isExpanded: false,
     currentNode: null as (NodeType | null),
@@ -344,6 +347,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
   // Handle pan XOR zoom (two-finger gestures count as onWheel)
   handleWheel = (e: any) => {
+    this.setState({ btnZooming: false });
 
     // Prevent nav gestures if mouse is over InfoPanel or ButtonSection
     if (!this.state.suppressDisplay) {
@@ -363,6 +367,14 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     }
   };
 
+  btnZoomIn = () => {
+    this.setState({ scale: 1.24, btnZooming: true});
+  }
+
+  btnZoomOut = () => {
+    this.setState({ scale: 0.76, btnZooming: true });
+  }
+
   toggleExpanded = () => {
     this.setState({ isExpanded: !this.state.isExpanded }, () => {
       this.props.setSidebar(!this.state.isExpanded);
@@ -385,8 +397,21 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   renderNodes = () => {
     let { activeIds, originX, originY, cursorX, cursorY, scale, panX, panY, anchorX, anchorY, relocateAllowed } = this.state;
 
-    return this.state.nodes.map((node: NodeType, i: number) => {
+    let minX = 0;
+    let maxX = 0;
+    let minY = 0;
+    let maxY = 0;
+    this.state.nodes.map((node: NodeType, i: number) => { 
+      if (node.x < minX) 
+      minX = (node.x < minX) ? node.x : minX;
+      maxX = (node.x > maxX) ? node.x : maxX;
+      minY = (node.y < minY) ? node.y : minY;
+      maxY = (node.y > maxY) ? node.y : maxY;
+    });
+    let midX = (minX + maxX)/2;
+    let midY = (minY + maxY)/2;
 
+    return this.state.nodes.map((node: NodeType, i: number) => {
       // Update position if not highlighting and active
       if (activeIds.includes(node.id) && relocateAllowed && !anchorX && !anchorY) {
         node.x = cursorX + node.toCursorX;
@@ -401,8 +426,14 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
       // Apply cursor-centered zoom
       if (this.state.scale !== 1) {
-        node.x = cursorX + scale * (node.x - cursorX);
-        node.y = cursorY + scale * (node.y - cursorY);
+        if (!this.state.btnZooming) {
+          node.x = cursorX + scale * (node.x - cursorX);
+          node.y = cursorY + scale * (node.y - cursorY);
+        } else {
+          console.log('hi')
+          node.x = midX + scale * (node.x - midX);
+          node.y = midY + scale * (node.y - midY);
+        }
       }
 
       // Apply pan 
@@ -510,6 +541,10 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           isExpanded={this.state.isExpanded}
           showRevisions={this.props.showRevisions}
         />
+        <ZoomPanel
+          btnZoomIn={this.btnZoomIn}
+          btnZoomOut={this.btnZoomOut}
+        />
       </StyledGraphDisplay>
     );
   }

+ 95 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx

@@ -0,0 +1,95 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+    btnZoomIn: () => void,
+    btnZoomOut: () => void,
+};
+
+type StateType = {
+  wrapperHeight: number
+};
+
+export default class ZoomPanel extends Component<PropsType, StateType> {
+  state = {
+    wrapperHeight: 0
+  }
+
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    this.setState({ wrapperHeight: this.wrapperRef.offsetHeight });
+  }
+
+  renderContents = () => {
+    return (
+      <Div>
+        <IconWrapper onClick={this.props.btnZoomIn}>
+          <i className="material-icons">add</i>
+        </IconWrapper>
+        <ZoomBreaker />
+        <IconWrapper onClick={this.props.btnZoomOut}>
+          <i className="material-icons">remove</i>
+        </IconWrapper>
+      </Div>
+    )
+  }
+
+  render() {
+    return (
+      <StyledZoomer>
+        {this.renderContents()}
+      </StyledZoomer>
+    );
+  }
+}
+
+const Div = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  height: calc(100% - 7px);
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: -4px;
+  margin-bottom: -4px;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    color: #ffffff;
+  }
+`;
+
+const StyledZoomer = styled.div`
+  position: absolute;
+  left: 15px;
+  bottom: 15px;
+  color: #ffffff;
+  height: 64px;
+  width: 36px;
+  background: #34373Cdf;
+  border-radius: 3px;
+  padding-left: 11px;
+  display: inline-block;
+  z-index: 999;
+  padding-top: 7px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  padding-right: 11px;
+  cursor: default;
+`;
+
+const ZoomBreaker = styled.div`
+  background: #ffffff20;
+  height: 1px;
+  width: 22px;
+`;

+ 102 - 105
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,68 +1,111 @@
+import { render } from '@testing-library/react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
 
+import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
-import StatusPlaceholderContainer from './StatusPlaceholderContainer';
+import { InfraType } from '../../../shared/types';
+import api from '../../../shared/api';
+
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 type PropsType = {
   setCurrentView: (x: string) => void,
+  projectId: number | null,
 };
 
 type StateType = {
+  infras: InfraType[],
 };
 
 export default class Dashboard extends Component<PropsType, StateType> {
-  renderDashboardIcon = () => {
-    let { currentProject } = this.context;
-    return (
-      <DashboardIcon>
-        <DashboardImage src={gradient} />
-        <Overlay>{currentProject && currentProject.name[0].toUpperCase()}</Overlay>
-      </DashboardIcon>
-    );
+  state = {
+    infras: [] as InfraType[],
   }
 
-  renderContents = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
-      return (
-        <div>
-          <TitleSection>
-            {this.renderDashboardIcon()}
-            <Title>{currentProject && currentProject.name}</Title>
-            <i
-              className="material-icons"
-              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
-                currentProject: currentProject,
-                setCurrentView: this.props.setCurrentView,
-              })}
-            >
-              more_vert
-          </i>
-          </TitleSection>
-
-          <InfoSection>
-            <TopRow>
-              <InfoLabel>
-                <i className="material-icons">info</i> Info
-            </InfoLabel>
-            </TopRow>
-            <Description>Project overview for {currentProject && currentProject.name}.</Description>
-          </InfoSection>
-
-          <LineBreak />
-
-          <StatusPlaceholderContainer setCurrentView={this.props.setCurrentView} />
-        </div>
-      );
+  refreshInfras = () => {
+    if (this.props.projectId) {
+      api.getInfra('<token>', {}, { 
+        project_id: this.props.projectId,
+      }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        } 
+        this.setState({ infras: res.data });
+      });
     }
   }
+  
+  componentDidMount() {
+    this.refreshInfras();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
+      this.refreshInfras();
+    }
+  }
+
+  onShowProjectSettings = () => {
+    let { currentProject, setCurrentModal } = this.context;
+    let { setCurrentView } = this.props;
+    setCurrentModal('UpdateProjectModal', { 
+      currentProject: currentProject,
+      setCurrentView: setCurrentView,
+    });
+  }
 
   render() {
+    let { currentProject, currentCluster } = this.context;
+    let { setCurrentView } = this.props;
+    let { infras } = this.state;
+    let { onShowProjectSettings } = this;
     return (
       <>
-        {this.renderContents()}
+        {currentProject && (
+          <DashboardWrapper>
+            <TitleSection>
+            <DashboardIcon>
+              <DashboardImage src={gradient} />
+              <Overlay>
+                {currentProject && currentProject.name[0].toUpperCase()}
+              </Overlay>
+            </DashboardIcon>
+              <Title>{currentProject && currentProject.name}</Title>
+              <i
+                className="material-icons"
+                onClick={onShowProjectSettings}
+              >
+                more_vert
+              </i>
+            </TitleSection>
+
+            <InfoSection>
+              <TopRow>
+                <InfoLabel>
+                  <i className="material-icons">info</i> Info
+              </InfoLabel>
+              </TopRow>
+              <Description>
+                Project overview for {currentProject && currentProject.name}.
+              </Description>
+            </InfoSection>
+
+            <LineBreak />
+
+            {!currentCluster && (
+              <Banner>
+                <i className="material-icons">error_outline</i>
+                This project currently has no clusters connected.
+              </Banner>
+            )}
+            <ProvisionerSettings 
+              setCurrentView={setCurrentView} 
+              infras={infras}
+            />
+          </DashboardWrapper>
+        )}
       </>
     );
   }
@@ -70,20 +113,24 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
 Dashboard.contextType = Context;
 
-const Placeholder = styled.div`
+const DashboardWrapper = styled.div`
+  padding-bottom: 100px;
+`;
+
+const Banner = styled.div`
+  height: 40px;
   width: 100%;
-  height: calc(100vh - 380px);
-  margin-top: 30px;
+  margin: 10px 0 30px;
+  font-size: 13px;
   display: flex;
-  padding-bottom: 20px;
-  align-items: center;
-  justify-content: center;
-  color: #aaaabb;
   border-radius: 5px;
-  text-align: center;
-  font-size: 13px;
-  background: #ffffff08;
-  font-family: 'Work Sans', sans-serif;
+  padding-left: 15px;
+  align-items: center;
+  background: #616FEEcc;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
 `;
 
 const TopRow = styled.div`
@@ -119,56 +166,6 @@ const InfoSection = styled.div`
   margin-bottom: 35px;
 `;
 
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: 'Work Sans', sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 30px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: not-allowed;
-
-  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
-  :hover {
-    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7A838Fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;

+ 42 - 2
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -8,6 +8,7 @@ import api from '../../../shared/api';
 type PropsType = {
   setCurrent: (x: any) => void,
   integrations: string[],
+  titles?: string[],
   isCategory?: boolean
 };
 
@@ -16,8 +17,32 @@ type StateType = {
 
 export default class IntegrationList extends Component<PropsType, StateType> {
   renderContents = () => {
-    let { integrations, setCurrent, isCategory } = this.props;
-    if (integrations && integrations.length > 0) {
+    let { integrations, titles, setCurrent, isCategory } = this.props;
+    if (titles && titles.length > 0) {
+      return integrations.map((integration: string, i: number) => {
+        let icon = integrationList[integration] && integrationList[integration].icon;
+        let subtitle = integrationList[integration] && integrationList[integration].label;
+        let label = titles[i];
+        let disabled = integration === 'repo' || integration === 'kubernetes';
+        return (
+          <Integration
+            key={i}
+            onClick={() => disabled ? null : setCurrent(integration)}
+            isCategory={isCategory}
+            disabled={disabled}
+          >
+            <Flex>
+              <Icon src={icon && icon} />
+              <Description>
+                <Label>{label}</Label>
+                <Subtitle>{subtitle}</Subtitle>
+              </Description>
+            </Flex>
+            <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
+          </Integration>
+        );
+      });
+    } else if (integrations && integrations.length > 0) {
       return integrations.map((integration: string, i: number) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         let label = integrationList[integration] && integrationList[integration].label;
@@ -90,12 +115,27 @@ const Integration = styled.div`
   }
 `;
 
+const Description = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+`;
+
 const Label = styled.div`
   color: #ffffff;
   font-size: 14px;
   font-weight: 500;
 `;
 
+const Subtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding-top: 5px;
+`;
+
 const Icon = styled.img`
   width: 30px;
   margin-right: 18px;

+ 29 - 3
dashboard/src/main/home/integrations/Integrations.tsx

@@ -16,6 +16,7 @@ type StateType = {
   currentCategory: string | null,
   currentIntegration: string | null,
   currentOptions: any[],
+  currentTitles: any[],
   currentIntegrationData: any[],
 };
 
@@ -24,6 +25,7 @@ export default class Integrations extends Component<PropsType, StateType> {
     currentCategory: null as string | null,
     currentIntegration: null as string | null,
     currentOptions: [] as any[],
+    currentTitles: [] as any[],
     currentIntegrationData: [] as any[],
   }
 
@@ -45,11 +47,25 @@ export default class Integrations extends Component<PropsType, StateType> {
           if (err) {
             console.log(err);
           } else {
+            // Sort res.data into service type and sort each service's registry alphabetically
+            let grouped: any = {}
+            let final: any = [];
+            for (let i = 0; i < res.data.length; i++) {
+              let p = res.data[i].service;
+              if (!grouped[p]) { grouped[p] = []; }
+              grouped[p].push(res.data[i]);
+            }
+            Object.values(grouped).forEach((val: any) => {
+              final = final.concat(val.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1));
+            });
+
             let currentOptions = [] as string[];
-            res.data.forEach((integration: any, i: number) => {
-              currentOptions.includes(integration.service) ? null : currentOptions.push(integration.service);
+            let currentTitles = [] as string[];
+            final.forEach((integration: any, i: number) => {
+              currentOptions.push(integration.service);
+              currentTitles.push(integration.name);
             });
-            this.setState({ currentOptions, currentIntegrationData: res.data });
+            this.setState({ currentOptions, currentTitles, currentIntegrationData: res.data });
           }
         });
         break;
@@ -150,8 +166,11 @@ export default class Integrations extends Component<PropsType, StateType> {
             </Button>
           </TitleSectionAlt>
 
+          <LineBreak />
+
           <IntegrationList
             integrations={this.state.currentOptions}
+            titles={this.state.currentTitles}
             setCurrent={(x: string) => this.setState({ currentIntegration: x })}
           />
         </div>
@@ -293,4 +312,11 @@ const StyledIntegrations = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
   padding-top: 45px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 32px 0px 24px;
 `;

+ 0 - 277
dashboard/src/main/home/modals/UpdateProjectModal.tsx

@@ -1,277 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import close from '../../../assets/close.png';
-import gradient from '../../../assets/gradient.jpg';
-
-import api from '../../../shared/api';
-import { Context } from '../../../shared/Context';
-import { ClusterType } from '../../../shared/types';
-
-import SaveButton from '../../../components/SaveButton';
-import InputRow from '../../../components/values-form/InputRow';
-import ConfirmOverlay from '../../../components/ConfirmOverlay';
-
-type PropsType = {
-};
-
-type StateType = {
-  projectName: string,
-  status: string | null,
-  showDeleteOverlay: boolean
-};
-
-export default class UpdateProjectModal extends Component<PropsType, StateType> {
-  state = {
-    projectName: this.context.currentModalData.currentProject.name,
-    status: null as string | null,
-    showDeleteOverlay: false,
-  };
-
-  // Possibly consolidate into context (w/ ProjectSection + NewProject)
-  getProjects = () => {
-    let { user, currentProject, projects, setProjects } = this.context;
-    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else if (res.data) {
-        setProjects(res.data);
-        if (res.data.length > 0) {
-          this.context.setCurrentProject(res.data[0]);
-        } else {
-          this.context.currentModalData.setCurrentView('new-project');
-        }
-        this.context.setCurrentModal(null, null);
-      }
-    });
-  }
-  
-  // TODO: Handle update to unmounted component
-  handleDelete = () => {
-    let { currentProject } = this.context;
-    this.setState({ status: 'loading' });
-    api.deleteProject('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
-      if (err) {
-        this.setState({ status: 'error' });
-        // console.log(err)
-      } else {
-        this.getProjects();
-        this.setState({ status: 'successful', showDeleteOverlay: false });
-      }
-    });
-
-    // Loop through and delete infra of all clusters we've provisioned
-    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        res.data.forEach((cluster: ClusterType) => {
-
-          // Handle destroying infra we've provisioned
-          if (cluster.infra_id) {
-            console.log('destroying provisioned infra...', cluster.infra_id);
-            api.destroyCluster('<token>', { eks_name: cluster.name }, { 
-              project_id: currentProject.id,
-              infra_id: cluster.infra_id,
-            }, (err: any, res: any) => {
-              if (err) {
-                this.setState({ status: 'error' });
-                console.log(err)
-              } else {
-                console.log('destroyed provisioned infra:', cluster.infra_id);
-              }
-            });
-          }
-        });
-      }
-    });
-  }
-
-  render() {
-    return (
-      <StyledUpdateProjectModal>
-        <CloseButton onClick={() => {
-          this.context.setCurrentModal(null, null);
-        }}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Project Settings</ModalTitle>
-        <Subtitle>
-          Project name
-        </Subtitle>
-
-        <InputWrapper>
-          <ProjectIcon>
-            <ProjectImage src={gradient} />
-            <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
-          </ProjectIcon>
-          <InputRow
-            disabled={true}
-            type='string'
-            value={this.state.projectName}
-            setValue={(x: string) => this.setState({ projectName: x })}
-            placeholder='ex: perspective-vortex'
-            width='470px'
-          />
-        </InputWrapper>
-
-        <Warning highlight={true}>
-          ⚠️ Deletion may result in dangling resources. Please visit the AWS console to ensure that all resources have been removed.
-        </Warning>
-        <Help 
-          href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws#deleting-provisioned-resources'
-          target='_blank'
-        >
-          <i className="material-icons">help_outline</i> Help
-        </Help>
-
-        <SaveButton
-          text='Delete Project'
-          color='#b91133'
-          onClick={() => this.setState({ showDeleteOverlay: true })}
-          status={this.state.status}
-        />
-
-        <ConfirmOverlay
-          show={this.state.showDeleteOverlay}
-          message={`Are you sure you want to delete ${this.state.projectName}?`}
-          onYes={this.handleDelete}
-          onNo={() => this.setState({ showDeleteOverlay: false })}
-        />
-      </StyledUpdateProjectModal>
-    );
-  }
-}
-
-UpdateProjectModal.contextType = Context;
-
-const Help = styled.a`
-  position: absolute;
-  left: 31px;
-  bottom: 35px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff55;
-  font-size: 13px;
-  :hover {
-    color: #ffffff;
-  }
-
-  > i {
-    margin-right: 9px;
-    font-size: 16px;
-  }
-`;
-
-const Warning = styled.div`
-  font-size: 13px;
-  display: flex;
-  border-radius: 3px;
-  width: calc(100%);
-  margin-top: 10px;
-  margin-left: 2px;
-  line-height: 1.4em;
-  align-items: center;
-  color: white;
-  > i {
-    margin-right: 10px;
-    font-size: 18px;
-  }
-  color: ${(props: { highlight: boolean, makeFlush?: boolean }) => props.highlight ? '#f5cb42' : ''};
-`;
-
-const Letter = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  display: flex;
-  color: white;
-  align-items: center;
-  justify-content: center;
-`;
-
-const ProjectImage = styled.img`
-  width: 100%;
-  height: 100%;
-`;
-
-const ProjectIcon = styled.div`
-  width: 25px;
-  min-width: 25px;
-  height: 25px;
-  border-radius: 3px;
-  overflow: hidden;
-  position: relative;
-  margin-right: 10px;
-  font-weight: 400;
-  margin-top: 14px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-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 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;

+ 19 - 472
dashboard/src/main/home/new-project/NewProject.tsx

@@ -1,466 +1,40 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
-import close from '../../../assets/close.png';
 
-import api from '../../../shared/api';
+import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
-import { integrationList } from '../../../shared/common';
-import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
 
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
-import Heading from '../../../components/values-form/Heading';
-import SaveButton from '../../../components/SaveButton';
-
-const providers = ['aws', 'gcp', 'do',];
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 type PropsType = {
   setCurrentView: (x: string, data?: any) => void,
 };
 
 type StateType = {
-  projectExists: boolean,
   projectName: string,
   selectedProvider: string | null,
-  awsRegion: string | null,
-  awsAccessId: string | null,
-  awsSecretKey: string | null,
-  gcpRegion: string | null,
-  gcpProjectId: string | null,
-  gcpKeyData: string | null,
-  status: string | null,
 };
 
 export default class NewProject extends Component<PropsType, StateType> {
   state = {
-    projectExists: false,
     projectName: '',
     selectedProvider: null as string | null,
-    awsRegion: '' as string | null,
-    awsAccessId: '' as string | null,
-    awsSecretKey: '' as string | null,
-    gcpRegion: '' as string | null,
-    gcpProjectId: '' as string | null,
-    gcpKeyData: '' as string | null,
-    status: null as string | null,
-  }
-
-  isAlphanumeric = (x: string) => {
-    let re = /^[a-z0-9-]+$/;
-    if (x.length == 0 || x.search(re) === -1) {
-      return false;
-    }
-    return true;
-  }
-
-  handleSelectProvider = (provider: string) => {
-    this.setState({ selectedProvider: provider });
-  }
-
-  renderProviderList = () => {
-    return providers.map((provider: string, i: number) => {
-      let providerInfo = integrationList[provider];
-      return (
-        <Block
-          key={i} 
-          onClick={() => this.handleSelectProvider(provider)}
-        >
-          <Icon src={providerInfo.icon} />
-          <BlockTitle>
-            {providerInfo.label}
-          </BlockTitle>
-          <BlockDescription>
-            Hosted in your own cloud.
-          </BlockDescription>
-        </Block>
-      )
-    });
-  }
-
-  // TODO: split this out into a separate component
-  renderProvisioners = () => {
-    if (this.state.selectedProvider === 'aws') {
-
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <DarkMatter />
-          <Heading>
-            AWS Credentials
-            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' target='_blank'>
-              <i className="material-icons-outlined">help</i> 
-              Guide
-            </GuideButton>
-          </Heading>
-          <InputRow
-            type='text'
-            value={this.state.awsRegion}
-            setValue={(x: string) => this.setState({ awsRegion: x })}
-            label='📍 AWS Region'
-            placeholder='ex: mars-north-12'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='text'
-            value={this.state.awsAccessId}
-            setValue={(x: string) => this.setState({ awsAccessId: x })}
-            label='👤 AWS Access ID'
-            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='password'
-            value={this.state.awsSecretKey}
-            setValue={(x: string) => this.setState({ awsSecretKey: x })}
-            label='🔒 AWS Secret Key'
-            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
-            width='100%'
-            isRequired={true}
-          />
-        </FormSection>
-      );
-    } else if (this.state.selectedProvider === 'gcp') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <DarkMatter />
-          <Heading>
-            GCP Credentials
-            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' target='_blank'>
-              <i className="material-icons-outlined">help</i> 
-              Guide
-            </GuideButton>
-          </Heading>
-          <InputRow
-            type='text'
-            value={this.state.gcpRegion}
-            setValue={(x: string) => this.setState({ gcpRegion: x })}
-            label='📍 GCP Region'
-            placeholder='ex: us-central1-a'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='text'
-            value={this.state.gcpProjectId}
-            setValue={(x: string) => this.setState({ gcpProjectId: x })}
-            label='🏷️ GCP Project ID'
-            placeholder='ex: pale-moon-24601'
-            width='100%'
-            isRequired={true}
-          />
-          <InputRow
-            type='password'
-            value={this.state.gcpKeyData}
-            setValue={(x: string) => this.setState({ gcpKeyData: x })}
-            label='🔒 GCP Key Data'
-            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
-            width='100%'
-            isRequired={true}
-          />
-        </FormSection>
-      );
-    } else if (this.state.selectedProvider === 'do') {
-      return (
-        <FormSection>
-          <CloseButton onClick={() => {
-            this.setState({ selectedProvider: null });
-          }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-          <Flex>
-            DigitalOcean support is in closed beta. If you would like to run Porter in your own DO account, email <Highlight>contact@getporter.dev</Highlight>.
-          </Flex>
-        </FormSection>
-      );
-    }
-
-    return (
-      <BlockList>
-        {this.renderProviderList()}
-      </BlockList>
-    );
-  }
-
-  renderHostingSection = () => {
-    if (this.state.selectedProvider === 'skipped') {
-      return (
-        <>
-          <Helper>Select your hosting backend:</Helper>
-          <Placeholder>
-            You can manually link to an existing cluster once this project has been created.
-          </Placeholder>
-          <Helper>
-            Don't have a Kubernetes cluster?
-            <Highlight onClick={() => this.setState({ selectedProvider: null })}>
-              Provision through Porter
-            </Highlight>
-          </Helper>
-        </>
-      )
-    }
-
-    return (
-      <>
-        <Helper>
-          Select your hosting backend: <Required>*</Required>
-        </Helper>
-        {this.renderProvisioners()}
-        <Helper>
-          Already have a Kubernetes cluster? 
-          <Highlight onClick={() => {
-            if (this.state.projectExists) {
-              this.props.setCurrentView('dashboard');
-            } else {
-              this.setState({ selectedProvider: 'skipped' });
-            }
-          }}>
-            Skip
-          </Highlight>
-        </Helper>
-      </>
-    )
-  }
-
-  validateForm = () => {
-    let { 
-      projectName,
-      selectedProvider, 
-      awsAccessId, 
-      awsSecretKey, 
-      awsRegion,
-      gcpRegion,
-      gcpKeyData,
-      gcpProjectId,
-    } = this.state;
-    if (!this.isAlphanumeric(projectName) || projectName === '') {
-      return false;
-    } else if (selectedProvider === 'aws') {
-      return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    } else if (selectedProvider === 'gcp') {
-      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
-    } else if (selectedProvider === 'skipped') {
-      return true;
-    }
-    return false;
   }
 
-  provisionECR = (proj: ProjectType, callback: (proj: ProjectType, ecr: any) => void) => {
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-
-    api.createAWSIntegration('<token>', {
-      aws_region: awsRegion,
-      aws_access_key_id: awsAccessId,
-      aws_secret_access_key: awsSecretKey,
-    }, { id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-        return;
-      }
-
-      api.provisionECR('<token>', {
-        aws_integration_id: res.data.id,
-        ecr_name: `${proj.name}-registry`
-      }, {id: proj.id}, (err: any, ecr:any) => {
-        if (err) {
-          this.setState({ 
-            projectExists: true,
-            status: 'Please provide valid credentials.',
-          });
-          return;
-        }
-
-        callback(proj, ecr);
-      })
-      
-    });
-  }
-
-  provisionEKS = (proj: ProjectType, ecr: any) => {
-    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
-    let clusterName = `${proj.name}-cluster`
-
-    api.createAWSIntegration('<token>', {
-      aws_region: awsRegion,
-      aws_access_key_id: awsAccessId,
-      aws_secret_access_key: awsSecretKey,
-      aws_cluster_id: clusterName,
-    }, { id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-        return;
-      }
-
-      api.provisionEKS('<token>', {
-        aws_integration_id: res.data.id,
-        eks_name: clusterName,
-      }, { id: proj.id}, (err: any, eks: any) => {
-        if (err) {
-          this.setState({ 
-            projectExists: true,
-            status: 'Please provide valid credentials.',
-          });
-          return;
-        }
-
-        this.props.setCurrentView('provisioner', [
-          { infra_id: ecr?.data?.id, kind: ecr?.data?.kind },
-          { infra_id: eks?.data?.id, kind: eks?.data?.kind },
-        ]);
-      })
-    })
-  }
-
-  provisionGKE = (proj: ProjectType, id: number) => {
-    let clusterName = `${proj.name}-cluster`
-    console.log('provisioning gke...');
-    api.createGKE('<token>', {
-      gke_name: clusterName,
-      gcp_integration_id: id,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        
-        // TODO: set to provisioner
-        alert('success');
-      }
-    });
-  }
-
-  provisionGCR = (proj: ProjectType, id: number) => {
-    console.log('provisioning gcr...');
-    api.createGCR('<token>', {
-      gcp_integration_id: id,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        console.log('gcr provisioned with response: ', res.data);
-        this.provisionGKE(proj, id);
-      }
-    });
-  }
-
-  provisionGCP = (proj: ProjectType) => {
-    this.setState({ status: 'loading' });
-
-    let { gcpRegion, gcpKeyData, gcpProjectId } = this.state;
-    console.log('provisioning gcp...');
-    api.createGCPIntegration('<token>', {
-      gcp_region: gcpRegion,
-      gcp_key_data: gcpKeyData,
-      gcp_project_id: gcpProjectId,
-    }, { project_id: proj.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res?.data) {
-        console.log('gcp provisioned with response: ', res.data);
-        let { id } = res.data;
-        this.provisionGCR(proj, id);
-      }
-    });
-  }
-
-  createProject = () => {
-    this.setState({ status: 'loading' });
-    api.createProject('<token>', {
-      name: this.state.projectName
-    }, {}, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        let { user } = this.context;
-        api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-          if (err) {
-            console.log(err)
-          } else if (res.data) {
-            this.context.setProjects(res.data);
-            if (res.data.length > 0) {
-              let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
-              this.context.setCurrentProject(proj);
-              
-              if (this.state.selectedProvider === 'aws') {
-                this.provisionECR(proj, this.provisionEKS);
-              } else if (this.state.selectedProvider === 'gcp') { 
-                this.provisionGCP(proj);
-              } else {
-                this.props.setCurrentView('dashboard', null);
-              }
-            } 
-          }
-        });
-      }
-    });
-  }
-
-  createInfra = () => {
-    this.setState({ status: 'loading' });
-    let { user } = this.context;
-    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else if (res.data) {
-        this.context.setProjects(res.data);
-        if (res.data.length > 0) {
-          let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
-          this.context.setCurrentProject(proj);
-          if (this.state.selectedProvider === 'aws') {
-            this.provisionECR(proj, this.provisionEKS)
-
-          } else {
-            this.props.setCurrentView('dashboard', null);
-          }
-        } 
-      }
-    });
-  }
-
-  renderHeaderSection = () => {
-    if (this.state.projectExists) {
-      return (
-        <>
-          <TitleSection>
-            <Title>Configure Hosting</Title>
-          </TitleSection>
-          <Helper>     
-            <Warning highlight={true} makeFlush={true}>
-              There was an issue configuring your cloud provider.
-            </Warning>
-          </Helper>
-          <Helper>     
-            You can refer to our docs for instructions on 
-            <Link 
-              href="https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-              target="_blank"
-            >
-              creating AWS credentials for Porter
-            </Link>.
-          </Helper>
-          <br />
-        </>
-      );
-    }
-
+  render() {
+    let { setCurrentView } = this.props;
+    let { projectName } = this.state;
     return (
-      <>
+      <StyledNewProject>
         <TitleSection>
           <Title>New Project</Title>
         </TitleSection>
         <Helper>
           Project name
-          <Warning highlight={!this.isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
+          <Warning highlight={!isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
             (lowercase letters, numbers, and "-" only)
           </Warning>
           <Required>*</Required>
@@ -478,43 +52,12 @@ export default class NewProject extends Component<PropsType, StateType> {
             width='470px'
           />
         </InputWrapper>
-      </>
-    );
-  }
-
-  renderButton = () => {
-    if (this.state.projectExists) {
-      return (
-        <SaveButton
-          text='Submit'
-          disabled={!this.validateForm()}
-          onClick={this.createInfra}
-          makeFlush={true}
-          helper='Note: Provisioning can take up to 15 minutes'
-          status={this.state.status}
+        <ProvisionerSettings 
+          isInNewProject={true}
+          setCurrentView={setCurrentView} 
+          projectName={projectName}
         />
-      );
-    }
-
-    return (
-      <SaveButton
-        text='Create Project'
-        disabled={!this.validateForm()}
-        onClick={this.createProject}
-        makeFlush={true}
-        helper='Note: Provisioning can take up to 15 minutes'
-        status={this.state.status}
-      />
-    );
-  }
-  
-  render() {
-    let { selectedProvider } = this.state;
-    return (
-      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
-        {this.renderHeaderSection()}
-        {this.renderHostingSection()}
-        {this.renderButton()}
+        <Br />
       </StyledNewProject>
     );
   }
@@ -522,6 +65,11 @@ export default class NewProject extends Component<PropsType, StateType> {
 
 NewProject.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 100px;
+`;
+
 const Link = styled.a`
   cursor: pointer;
   margin-left: 5px;
@@ -797,8 +345,7 @@ const TitleSection = styled.div`
 const StyledNewProject = styled.div`
   width: calc(90% - 150px);
   min-width: 300px;
-  height: ${(props: { height: string }) => props.height};
   position: relative;
   padding-top: 50px;
-  margin-top: ${(props: { height: string }) => props.height === '600px' ? 'calc(50vh - 350px)' : 'calc(50vh - 400px)'};
+  margin-top: calc(50vh - 340px);
 `;

+ 388 - 0
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -0,0 +1,388 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { InviteType } from '../../../shared/types';
+import Loading from '../../../components/Loading';
+import api from '../../../shared/api';
+import InputRow from '../../../components/values-form/InputRow';
+
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+}
+
+type StateType = {
+  loading: boolean,
+  invites: InviteType[],
+  email: string,
+  invalidEmail: boolean,
+}
+
+export default class InviteList extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    invites: [] as InviteType[],
+    email: '',
+    invalidEmail: false,
+  }
+
+  componentDidMount() {
+    this.getInviteData();
+  }
+
+  getInviteData = () => {
+    let { currentProject } = this.context;
+    
+    this.setState({ loading: true })
+    api.getInvites('<token>', {}, {
+      id: currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.setState({ invites: res.data, loading: false }, () => {
+          for (let i = this.state.invites.length - 1; i >= 0; i--) {
+            if (this.state.invites[i].expired && !this.state.invites[i].accepted) {
+              api.deleteInvite('<token>', {}, {
+                id: currentProject.id, invId: this.state.invites[i].id
+              }, (err: any, res: any) => {
+                if (err) {
+                  console.log(`Error deleting invite: ${err}`);
+                } else {
+                  this.state.invites.splice(i, 1);
+                }
+              })
+            }
+          }
+        });
+      }
+    });
+  }
+
+  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 });
+    }
+  }
+
+  createInvite = () => {
+    let { currentProject } = this.context;
+    api.createInvite('<token>', { email: this.state.email }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.getInviteData();
+        this.setState({ email: '' });
+      }
+    })
+  }
+
+  deleteInvite = (index: number) => {
+    let { currentProject } = this.context;
+    api.deleteInvite('<token>', {}, {
+      id: currentProject.id, invId: this.state.invites[index].id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.getInviteData();
+      }
+    })
+  }
+
+  replaceInvite = (index: number) => {
+    let { currentProject } = this.context;
+    api.createInvite('<token>', { email: this.state.invites[index].email }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.deleteInvite('<token>', {}, {
+          id: currentProject.id, invId: this.state.invites[index].id
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            this.getInviteData();
+          }
+        })
+      }
+    })
+  }
+
+  copyToClip = (index: number) => {
+    let { currentProject } = this.context;
+    navigator.clipboard.writeText(
+      `${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}`
+    ).then(function() {
+    }, function() {
+      console.log("couldn't copy link to clipboard");
+    })
+  }
+
+  renderInvitations = () => {
+    let { currentProject } = this.context;
+    if (this.state.loading) {
+      return (
+        <Loading />
+      )
+    } else {
+      var invContent: any[] = [];
+      for (let i = 0; i < this.state.invites.length; i++) {
+        if (this.state.invites[i].accepted) {
+          invContent.push(
+            <Tr key={i}>
+              <MailTd isTop={i === 0}>
+                {this.state.invites[i].email}
+              </MailTd>
+              <LinkTd isTop={i === 0}>
+              </LinkTd>
+              <Td isTop={i === 0}>
+                <CopyButton
+                  onClick={() => this.deleteInvite(i)}
+                >
+                  Remove
+                </CopyButton>
+              </Td>
+            </Tr>
+          )
+        } 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>
+                  <ShareLink
+                    disabled={true}
+                    type='string'
+                    placeholder='Link expired'
+                  />
+                  <CopyButton
+                    onClick={() => this.replaceInvite(i)}
+                  >
+                    Get New Link
+                  </CopyButton>
+                </Rower>
+              </LinkTd>
+              <Td isTop={i === 0}>
+                <CopyButton
+                  onClick={() => this.deleteInvite(i)}
+                >
+                  Delete Invite
+                </CopyButton>
+              </Td>
+            </Tr>
+          )
+        } 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={`${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[i].token}`}
+                    placeholder='Unable to retrieve link'
+                  />
+                  <CopyButton
+                    onClick={() => this.copyToClip(i)}
+                  >
+                    Copy Link
+                  </CopyButton>
+                </Rower>
+              </LinkTd>
+              <Td isTop={i === 0}>
+                <CopyButton
+                  onClick={() => this.deleteInvite(i)}
+                >
+                  Delete Invite
+                </CopyButton>
+              </Td>
+            </Tr>
+          )
+        }
+      }
+      return (
+        <>
+          <Subsubtitle>Collaborators</Subsubtitle>
+          {invContent.length > 0
+            ? <Table><tbody>{invContent}</tbody></Table>
+            : <BodyText>This project currently has no collaborators.</BodyText>
+          }
+        </>
+      )
+    }
+  }
+
+  render() {
+    return (
+      <>
+        <Subtitle>Manage Access</Subtitle>
+        <CreateInvite>
+          <InputRow
+            label='Invite Collaborators'
+            value={this.state.email}
+            type='text'
+            setValue={(x: string) => this.setState({ email: x })}
+            width='324px'
+            placeholder='ex. mrp@getporter.dev'
+          />
+          <InviteButton
+            onClick={() => this.validateEmail()}
+          >
+            Invite!
+          </InviteButton>
+        </CreateInvite>
+        {this.state.invalidEmail &&
+          <Invalid>
+            Invalid Email Address. Try Again.
+          </Invalid>
+        }
+        {this.renderInvitations()}
+      </>
+    )
+  }
+}
+
+InviteList.contextType = Context;
+
+const Subtitle = styled.div`
+  font-size: 18px;
+  font-weight: 700;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 24px;
+  margin-top: 32px;
+`;
+
+const Subsubtitle = styled.div`
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 12px;
+`;
+
+const BodyText = styled.div`
+  color: #ffffff66;
+  font-weight: 400;
+  font-size: 13px;
+`;
+
+const CopyButton = styled.div`
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin-left: 12px;
+  float: right;
+  width: 128px;
+  padding-top: 7px;
+  padding-bottom: 6px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  text-align: center;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const InviteButton = styled(CopyButton)`
+  margin-bottom: 14px;
+`;
+
+const Rower = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+`;
+
+const CreateInvite = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: flex-end;
+  margin-top: -20px;
+  margin-bottom: 14px;
+`;
+
+const ShareLink = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  width: 50%;
+  color: #74a5f7;
+  padding: 5px 10px;
+  height: 30px;
+  text-overflow: ellipsis;
+  border-radius: 3px;
+  ::placeholder,
+  ::-webkit-input-placeholder {
+    color: #fa0a26;
+    font-weight: 600;
+  }
+`;
+
+const Spacer = styled.div`
+  height: 24px;
+`;
+
+const Table = styled.table`
+  width: 100%;
+  border-spacing: 0px;
+  border: 1px solid #ffffff55;
+  border-radius: 5px;
+`;
+
+const Td = styled.td`
+  white-space: nowrap;
+  padding: 20px 0px;
+  border-top: ${(props: {isTop: boolean}) => (props.isTop ? 'none' : '1px solid #ffffff55')};
+  &:last-child {
+    padding-right: 16px;
+  }
+`;
+
+const Tr = styled.tr`
+`;
+
+const MailTd = styled(Td)`
+  padding-left: 16px;
+  max-width: 242px;
+  min-width: 242px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+`;
+
+const LinkTd = styled(Td)`
+  width: 100%;
+`;
+
+const Invalid = styled.div`
+  margin-top: -26px;
+  margin-bottom: 26px;
+  color: #fa0a26;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;

+ 177 - 0
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -0,0 +1,177 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import InviteList from './InviteList';
+
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+}
+
+type StateType = {
+  projectName: string,
+}
+
+export default class ProjectSettings extends Component<PropsType, StateType> {
+  state = {
+    projectName: '',
+  }
+
+  componentDidMount() {
+    let { currentProject, user } = this.context;
+    this.setState({ projectName: currentProject.name });
+  }
+
+  renderTitle = () => {
+    let { currentProject } = this.context;
+    if (currentProject) {
+      return (
+        <>
+          <TitleSection>
+            <Title>Project Settings</Title>
+          </TitleSection>
+          <LineBreak />
+        </>
+      );
+    }
+  }
+
+  renderDelete = () => {
+    let { currentProject } = this.context;
+    if (currentProject) {
+      return (
+        <>
+          <Subtitle>Other Settings</Subtitle>
+          <Rower>
+            <BodyText>
+              Delete this project: 
+            </BodyText>
+            <DeleteButton
+              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
+                currentProject: currentProject,
+                setCurrentView: this.props.setCurrentView,
+              })}
+            >
+              Delete
+            </DeleteButton>
+          </Rower>
+        </>
+      )
+    }
+  }
+
+  renderContents = () => {
+    return (
+      <ContentHolder>
+          <InviteList />
+          {this.renderDelete()}
+      </ContentHolder>
+    )
+  }
+
+  render () {
+    return (
+      <StyledProjectSettings>
+        {this.renderTitle()}
+        {this.renderContents()}
+      </StyledProjectSettings>
+    );
+  }
+}
+
+ProjectSettings.contextType = Context;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  height: 40px;
+`;
+
+const StyledProjectSettings = styled.div`
+  width: calc(90% - 150px);
+  min-width: 300px;
+  padding-top: 45px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px -20px;
+`;
+
+const Subtitle = styled.div`
+  font-size: 18px;
+  font-weight: 700;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-bottom: 24px;
+  margin-top: 32px;
+`;
+
+const BodyText = styled.div`
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+`;
+
+const CopyButton = styled.div`
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin-left: 12px;
+  float: right;
+  width: 128px;
+  padding-top: 8px;
+  padding-bottom: 8px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  text-align: center;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const DeleteButton = styled(CopyButton)`
+  background-color: #b91133;
+  border: none;
+  width: 88px;
+  margin-left: 20px;
+  :hover {
+    background-color: #b91133;
+    filter: brightness(120%);
+    border: none;
+  }
+`;
+
+const ContentHolder = styled.div`
+  min-width: 420px;
+  width: 100%;
+  margin-bottom: 55px;
+`;
+
+const Rower = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+`;

+ 392 - 0
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -0,0 +1,392 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+import { isAlphanumeric } from '../../../shared/common';
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType, InfraType } from '../../../shared/types';
+
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void,
+  handleError: () => void,
+  projectName: string,
+  setCurrentView: (x: string | null, data?: any) => void,
+  infras: InfraType[],
+};
+
+type StateType = {
+  awsRegion: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+  selectedInfras: { value: string, label: string }[],
+  buttonStatus: string,
+};
+
+const provisionOptions = [
+  { value: 'ecr', label: 'Elastic Container Registry (ECR)' },
+  { value: 'eks', label: 'Elastic Kubernetes Service (EKS)' },
+];
+
+// TODO: Consolidate across forms w/ HOC
+export default class AWSFormSection extends Component<PropsType, StateType> {
+  state = {
+    awsRegion: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+    selectedInfras: [...provisionOptions],
+    buttonStatus: '',
+  }
+
+  componentDidMount = () => {
+    let { infras } = this.props;
+    let { selectedInfras } = this.state;
+
+    if (infras) {
+      
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      infras.forEach(
+        (infra: InfraType, i: number) => {
+          let { kind, status } = infra;
+          if (
+            kind === 'ecr'
+            && (status === 'creating' || status === 'created')
+          ) {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== 'ecr';
+            });
+          } else if (
+            kind === 'eks'
+            && (status === 'creating' || status === 'created')
+          ) {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== 'eks';
+            });
+          }
+        }
+      );
+      this.setState({ selectedInfras: filtered });
+    }
+  }
+
+  checkFormDisabled = () => {
+    let { 
+      awsRegion,
+      awsAccessId, 
+      awsSecretKey, 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return (
+        !isAlphanumeric(projectName) 
+          || !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '')
+          || selectedInfras.length === 0
+      );
+    } else {
+      return (
+        !(awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '')
+          || selectedInfras.length === 0
+      );
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+      currentProject 
+    } = this.context;
+
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      } else {
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+            handleError();
+            return;
+          }
+          setProjects(res.data);
+          if (res.data.length > 0) {
+            let tgtProject = res.data.find((el: ProjectType) => {
+              return el.name === projectName;
+            });
+            setCurrentProject(tgtProject);
+            callback && callback();
+          } 
+        });
+      }
+    });
+  }
+
+  provisionECR = (callback?: any) => {
+    console.log('Provisioning ECR')
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { currentProject } = this.context;
+    let { handleError } = this.props;
+
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+
+      api.provisionECR('<token>', {
+        aws_integration_id: res.data.id,
+        ecr_name: `${currentProject.name}-registry`
+      }, {id: currentProject.id}, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          handleError();
+          return;
+        }
+        callback && callback();
+      })
+      
+    });
+  }
+
+  provisionEKS = () => {
+    console.log('Provisioning EKS');
+    let { setCurrentView, handleError } = this.props;
+    let { awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { currentProject } = this.context;
+
+    let clusterName = `${currentProject.name}-cluster`
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+      aws_cluster_id: clusterName,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      api.provisionEKS('<token>', {
+        aws_integration_id: res.data.id,
+        eks_name: clusterName,
+      }, { id: currentProject.id}, (err: any, eks: any) => {
+        if (err) {
+          console.log(err);
+          handleError();
+          return;
+        }
+        setCurrentView('provisioner');
+      })
+    })
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateAWS = () => {
+    let { projectName, setCurrentView } = this.props;
+    let { selectedInfras } = this.state;
+
+    console.log(selectedInfras);
+    if (!projectName) {
+      console.log(selectedInfras)
+      if (selectedInfras.length === 2) {
+        // Case: project exists, provision ECR + EKS
+        this.provisionECR(this.provisionEKS);
+      } else if (selectedInfras[0].value === 'ecr') {
+        // Case: project exists, only provision ECR
+        this.provisionECR(() => setCurrentView('provisioner'));
+      } else {
+        // Case: project exists, only provision EKS
+        this.provisionEKS();
+      }
+    } else {
+      if (selectedInfras.length === 2) {
+        // Case: project DNE, provision ECR + EKS 
+        this.createProject(() => this.provisionECR(this.provisionEKS));
+      } else if (selectedInfras[0].value === 'ecr') {
+        // Case: project DNE, only provision ECR
+        this.createProject(() => this.provisionECR(() => {
+          setCurrentView('provisioner');
+        }));
+      } else {
+        // Case: project DNE, only provision EKS
+        this.createProject(this.provisionEKS);
+      }
+    }
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      awsRegion,
+      awsAccessId,
+      awsSecretKey,
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledAWSFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>
+            AWS Credentials
+            <GuideButton 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' 
+              target='_blank'
+            >
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={awsRegion}
+            setValue={(x: string) => this.setState({ awsRegion: x })}
+            label='📍 AWS Region'
+            placeholder='ex: us-east-2'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+          <Br />
+          <Heading>Resources</Heading>
+          <Helper>Porter will provision the following resources</Helper>
+          <CheckboxList
+            options={provisionOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        {this.props.children ? this.props.children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={this.checkFormDisabled()}
+          onClick={this.onCreateAWS}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledAWSFormSection>
+    );
+  }
+}
+
+AWSFormSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledAWSFormSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  margin-bottom: 25px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+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 GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;

+ 101 - 0
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { ProjectType } from '../../../shared/types';
+import { isAlphanumeric } from '../../../shared/common';
+import { Context } from '../../../shared/Context';
+
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  projectName: string,
+  setCurrentView: (x: string, data?: any) => void,
+};
+
+type StateType = {
+  buttonStatus: string,
+};
+
+export default class ExistingClusterSection extends Component<PropsType, StateType> {
+  state = {
+    buttonStatus: '',
+  }
+
+  onCreateProject = () => {
+    let { projectName, setCurrentView } = this.props;
+    let { user, setProjects, setCurrentProject } = this.context;
+
+    this.setState({ buttonStatus: 'loading' });
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err)
+          } else if (res.data) {
+            setProjects(res.data);
+            if (res.data.length > 0) {
+              let proj = res.data.find((el: ProjectType) => {
+                return el.name === projectName;
+              });
+              setCurrentProject(proj);
+              setCurrentView('dashboard', null);
+            } 
+          }
+        });
+      }
+    });
+  }
+
+  render() {
+    let { children, projectName } = this.props;
+    let { buttonStatus } = this.state;
+    return (
+      <StyledExistingClusterSection>
+        <Placeholder>
+          You can manually link to an existing cluster once this project has
+          been created.
+        </Placeholder>
+        {children ? children : <Padding />}
+        <SaveButton
+          text='Submit'
+          disabled={!isAlphanumeric(projectName)}
+          onClick={this.onCreateProject}
+          status={buttonStatus}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledExistingClusterSection>
+    );
+  }
+}
+
+ExistingClusterSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const StyledExistingClusterSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const Placeholder = styled.div`
+  margin-top: 25px;
+  background: #26282f;
+  margin-bottom: 27px;
+  border-radius: 5px;
+  height: 170px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;

+ 182 - 0
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -0,0 +1,182 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import close from '../../../assets/close.png';
+
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+import CheckboxList from '../../../components/values-form/CheckboxList';
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void,
+};
+
+type StateType = {
+  gcpRegion: string,
+  gcpProjectId: string,
+  gcpKeyData: string,
+  selectedInfras: { value: string, label: string }[],
+};
+
+const dummyOptions = [
+  { value: 'gcr', label: 'Google Container Registry (GCR)' },
+  { value: 'gke', label: 'Googke Kubernetes Engine (GKE)' },
+];
+
+export default class GCPFormSection extends Component<PropsType, StateType> {
+  state = {
+    gcpRegion: '',
+    gcpProjectId: '',
+    gcpKeyData: '',
+    selectedInfras: [] as { value: string, label: string }[],
+  }
+
+  render() {
+    let { setSelectedProvisioner } = this.props;
+    let {
+      gcpRegion,
+      gcpProjectId,
+      gcpKeyData,
+      selectedInfras,
+    } = this.state;
+
+    return (
+      <StyledGCPFormSection>
+        <FormSection>
+          <CloseButton onClick={() => setSelectedProvisioner(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Heading isAtTop={true}>
+            GCP Credentials
+            <GuideButton 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' 
+              target='_blank'
+            >
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: us-central1-a'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={gcpProjectId}
+            setValue={(x: string) => this.setState({ gcpProjectId: x })}
+            label='🏷️ GCP Project ID'
+            placeholder='ex: pale-moon-24601'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={gcpKeyData}
+            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+            label='🔒 GCP Key Data'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+          <Br />
+          <Heading>Resources</Heading>
+          <Helper>Porter will provision the following resources</Helper>
+          <CheckboxList
+            options={dummyOptions}
+            selected={selectedInfras}
+            setSelected={(x: { value: string, label: string }[]) => {
+              this.setState({ selectedInfras: x });
+            }}
+          />
+        </FormSection>
+        <SaveButton
+          text='Submit'
+          disabled={true}
+          onClick={() => console.log('oolala')}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+        />
+      </StyledGCPFormSection>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledGCPFormSection = styled.div`
+  position: relative;
+  padding-bottom: 70px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+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 GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;

+ 69 - 0
dashboard/src/main/home/provisioner/InfraStatuses.tsx

@@ -0,0 +1,69 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import loadingDots from '../../../assets/loading-dots.gif';
+import { InfraType } from '../../../shared/types';
+import { infraNames } from '../../../shared/common';
+
+type PropsType = {
+  infras: InfraType[],
+};
+
+type StateType = {
+};
+
+export default class InfraStatuses extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  renderStatusIcon = (status: string) => {
+    if (status === 'created') {
+      return <StatusIcon>✓</StatusIcon>;
+    } else if (status === 'creating') {
+      return <StatusIcon><img src={loadingDots} /></StatusIcon>
+    } else if (status === 'error') {
+      return <StatusIcon color='#e3366d'>✗</StatusIcon>
+    }
+  }
+
+  render() {
+    return (
+      <StyledInfraStatuses>
+        {this.props.infras.map((infra: InfraType, i: number) => {
+          return (
+            <InfraRow>
+              {this.renderStatusIcon(infra.status)}
+              {infraNames[infra.kind]}
+            </InfraRow>
+          )
+        })}
+      </StyledInfraStatuses>
+    );
+  }
+}
+
+const StatusIcon = styled.div<{ color?: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  font-size: 16px;
+  color: ${props => props.color ? props.color : '#68c49c'};
+  margin-right: 10px;
+`;
+
+const InfraRow = styled.div`
+  width: 100%;
+  height: 25px;
+  padding-left: 2px;
+  margin-top: 10px;
+  font-size: 13px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+`;
+
+const StyledInfraStatuses = styled.div`
+  margin-top: 20px;
+  margin-bottom: 0;
+`;

+ 269 - 0
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -0,0 +1,269 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+import { integrationList } from '../../../shared/common';
+import { InfraType } from '../../../shared/types';
+
+import Helper from '../../../components/values-form/Helper';
+import AWSFormSection from './AWSFormSection';
+import GCPFormSection from './GCPFormSection';
+import SaveButton from '../../../components/SaveButton';
+import ExistingClusterSection from './ExistingClusterSection';
+
+type PropsType = {
+  setCurrentView: (x: string, data?: any) => void,
+  isInNewProject?: boolean,
+  projectName?: string,
+  infras?: InfraType[],
+};
+
+type StateType = {
+  selectedProvider: string | null,
+  infras: InfraType[],
+};
+
+const providers = ['aws', 'gcp', 'do',];
+
+export default class NewProject extends Component<PropsType, StateType> {
+  state = {
+    selectedProvider: null as string | null,
+    infras: [] as InfraType[],
+  }
+
+  // Handle any submission (pre-status) error
+  handleError = () => {
+    let { setCurrentView } = this.props;
+    let { setCurrentError } = this.context;
+    setCurrentView('dashboard');
+    this.setState({ selectedProvider: null });
+    setCurrentError('Provisioning failed. Check your credentials and try again.');
+  }
+
+  renderSelectedProvider = () => {
+    let { selectedProvider } = this.state;
+    let { projectName, setCurrentView, infras } = this.props;
+
+    let renderSkipHelper = () => {
+      return (
+        <>
+          {selectedProvider === 'skipped' 
+            ? (
+              <Helper>
+                Don't have a Kubernetes cluster?
+                <Highlight 
+                  onClick={() => this.setState({ selectedProvider: null })}
+                >
+                  Provision through Porter
+                </Highlight>
+              </Helper>
+            ) : (
+              <PositionWrapper selectedProvider={selectedProvider}>
+                <Helper>
+                  Already have a Kubernetes cluster? 
+                  <Highlight 
+                    onClick={() => this.setState({ 
+                      selectedProvider: 'skipped' 
+                    })}
+                  >
+                    Skip
+                  </Highlight>
+                </Helper>
+              </PositionWrapper>
+            )
+          }
+        </>
+      );
+    }
+
+    switch (selectedProvider) {
+      case 'aws':
+        return (
+          <AWSFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setCurrentView={setCurrentView}
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          >
+            {renderSkipHelper()}
+          </AWSFormSection>
+        );
+      case 'gcp':
+        return (
+          <GCPFormSection 
+            setSelectedProvisioner={(x: string | null) => {
+              this.setState({ selectedProvider: x });
+            }}
+          />
+        );
+      case 'do':
+        return <h1>most</h1>;
+      default:
+        return (
+          <ExistingClusterSection 
+            projectName={projectName}
+            setCurrentView={setCurrentView}
+          >
+            {renderSkipHelper()}
+          </ExistingClusterSection>
+        );
+    }
+  }
+  
+  render() {
+    let { selectedProvider } = this.state;
+    let { isInNewProject } = this.props;
+    return (
+      <StyledProvisionerSettings>
+        <Helper>
+          Need a cluster? Provision through Porter: 
+          {isInNewProject && <Required>*</Required>}
+        </Helper>
+        {!selectedProvider ? (
+          <BlockList>
+            {providers.map((provider: string, i: number) => {
+              let providerInfo = integrationList[provider];
+              return (
+                <Block
+                  key={i} 
+                  onClick={() => {
+                    this.setState({ selectedProvider: provider });
+                  }}
+                >
+                  <Icon src={providerInfo.icon} />
+                  <BlockTitle>
+                    {providerInfo.label}
+                  </BlockTitle>
+                  <BlockDescription>
+                    Hosted in your own cloud.
+                  </BlockDescription>
+                </Block>
+              );
+            })}
+          </BlockList>
+        ) : (
+          <>{this.renderSelectedProvider()}</>
+        )}
+        {(isInNewProject && !selectedProvider) && (
+          <>
+            <Helper>
+              Already have a Kubernetes cluster? 
+              <Highlight 
+                onClick={() => this.setState({ selectedProvider: 'skipped' })}
+              >
+                Skip
+              </Highlight>
+            </Helper>
+            <Br />
+            <SaveButton
+              text='Submit'
+              disabled={true}
+              onClick={() => {}}
+              makeFlush={true}
+              helper='Note: Provisioning can take up to 15 minutes'
+            />
+          </>
+        )}
+      </StyledProvisionerSettings>
+    );
+  }
+}
+
+NewProject.contextType = Context;
+
+const Br = styled.div`
+  width: 100%;
+  height: 35px;
+`;
+
+const StyledProvisionerSettings = styled.div`
+  position: relative;
+`;
+
+const PositionWrapper = styled.div<{ selectedProvider: string | null}>`
+`;
+
+const Highlight = styled.div`
+  margin-left: 5px;
+  color: #8590ff;
+  cursor: pointer;
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const Icon = styled.img<{ bw?: boolean }>`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${props => props.bw ? 'grayscale(1)' : ''};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;  
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${props => props.disabled ? '' : 'pointer'};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${props => props.disabled ? '' : '#ffffff11'};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;

+ 99 - 67
dashboard/src/main/home/new-project/Provisioner.tsx → dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -6,13 +6,13 @@ import { Context } from '../../../shared/Context';
 import ansiparse from '../../../shared/ansiparser'
 import loading from '../../../assets/loading.gif';
 import warning from '../../../assets/warning.png';
+import { InfraType } from '../../../shared/types';
+import { filterOldInfras } from '../../../shared/common';
 
 import Helper from '../../../components/values-form/Helper';
-import { eventNames } from 'process';
-import { inflateRaw, inflateRawSync } from 'zlib';
+import InfraStatuses from './InfraStatuses';
 
 type PropsType = {
-  viewData: any,
   setCurrentView: (x: string) => void,
 }
 
@@ -23,8 +23,17 @@ type StateType = {
   maxStep : Record<string, number>,
   currentStep: Record<string, number>,
   triggerEnd: boolean,
+  infras: InfraType[],
 };
 
+const dummyInfras = [
+  { kind: 'ecr', status: 'creating', id: 5, project_id: 1 }, 
+  { kind: 'eks', status: 'error', id: 3, project_id: 1 },
+  { kind: 'eks', status: 'error', id: 1, project_id: 1 },
+  { kind: 'eks', status: 'error', id: 4, project_id: 1 },
+  { kind: 'ecr', status: 'created', id: 2, project_id: 1 },
+];
+
 export default class Provisioner extends Component<PropsType, StateType> {
   state = {
     error: false,
@@ -33,6 +42,44 @@ export default class Provisioner extends Component<PropsType, StateType> {
     maxStep: {} as Record<string, any>,
     currentStep: {} as Record<string, number>,
     triggerEnd: false,
+    infras: [] as InfraType[],
+  }
+
+  componentDidMount() {
+    let { currentProject } = this.context;
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+
+    // Check if current project is provisioning
+    api.getInfra('<token>', {}, { 
+      project_id: currentProject.id 
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } 
+      let infras = filterOldInfras(res.data);
+      let error = false;
+      infras.forEach((infra: InfraType, i: number) => {
+        if (infra.status === 'error') {
+          error = true;
+        }
+      });
+
+      // Filter historical infras list for most current instances of each
+      let websockets = infras.map((infra: any) => {
+        let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
+        return this.setupWebsocket(ws, infra)
+      });
+  
+      this.setState({ error, infras, websockets, logs: ["Provisioning resources..."] });
+    });
+  }
+
+  componentWillUnmount() {
+    if (!this.state.websockets) { return; }
+
+    this.state.websockets.forEach((ws: any) => {
+      ws.close()
+    })
   }
 
   scrollToBottom = () => {
@@ -127,69 +174,19 @@ export default class Provisioner extends Component<PropsType, StateType> {
     return ws
   }
 
-  componentDidMount() {
-    let { currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
-    let viewData = this.props.viewData || []
-
-    let websockets = viewData.map((infra: any) => {
-      let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${infra.kind}/${infra.infra_id}/logs`)
-      return this.setupWebsocket(ws, infra)
-    });
-
-    this.setState({ websockets, logs: ["Provisioning EKS cluster and ECR registry..."] });
-  }
-
-  componentWillUnmount() {
-    if (!this.state.websockets) { return; }
-
-    this.state.websockets.forEach((ws: any) => {
-      ws.close()
-    })
-  }
-
   scrollRef = React.createRef<HTMLDivElement>();
 
   renderLogs = () => {
     return this.state.logs.map((log, i) => {
-      return <Log key={i}>{log}</Log>
+      return <Log key={i}>{log}</Log>;
     });
   }
 
-  renderHeadingSection = () => {
-    if (this.state.error) {
-      return (
-        <>
-          <TitleSection>
-            <Title><img src={warning} /> Provisioning Error</Title>
-          </TitleSection>
-
-          <Helper>
-            Porter encountered an error while provisioning.
-            <Link onClick={() => this.props.setCurrentView('dashboard')}>
-              Exit to dashboard
-            </Link> 
-            to try again with new credentials.
-          </Helper>
-        </>
-      );
-    }
-
-    return (
-      <>
-        <TitleSection>
-          <Title><img src={loading} /> Setting Up Porter</Title>
-        </TitleSection>
-        <Helper>
-          Porter is currently being provisioned to your AWS account:
-        </Helper>
-      </>
-    )
-  }
-
   onEnd = () => {
     let myInterval = setInterval(() => {
-      api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
+      api.getClusters('<token>', {}, { 
+        id: this.context.currentProject.id 
+      }, (err: any, res: any) => {
         if (err) {
           console.log(err);
         } else if (res.data) {
@@ -204,6 +201,9 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
   
   render() {
+    let { error, triggerEnd, infras } = this.state;
+    let { setCurrentView } = this.props;
+    
     let maxStep = 0;
     let currentStep = 0;
 
@@ -219,23 +219,55 @@ export default class Provisioner extends Component<PropsType, StateType> {
       }
     }
 
-    if (maxStep !== 0 && currentStep === maxStep && !this.state.triggerEnd) {
+    if (maxStep !== 0 && currentStep === maxStep && !triggerEnd) {
       this.onEnd()
       this.setState({ triggerEnd: true });
     }
 
     return (
       <StyledProvisioner>
-        {this.renderHeadingSection()}
-
+        {error 
+          ? (
+            <>
+              <TitleSection>
+                <Title><img src={warning} /> Provisioning Error</Title>
+              </TitleSection>
+    
+              <Helper>
+                Porter encountered an error while provisioning.
+                <Link onClick={() => setCurrentView('dashboard')}>
+                  Exit to dashboard
+                </Link> 
+                to try again with new credentials.
+              </Helper>
+            </>
+          ) : (
+            <>
+              <TitleSection>
+                <Title><img src={loading} /> Setting Up Porter</Title>
+              </TitleSection>
+              <Helper>
+                Porter is currently provisioning resources in your cloud provider:
+              </Helper>
+            </>
+          )
+        }
+      
         <LoadingBar>
-          <Loaded progress={((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%'} />
+          <Loaded 
+            progress={
+              error ? (
+                '0%'
+              ) : (
+                (((currentStep / (maxStep == 0 ? 1 : maxStep)) * 100).toString() + '%')
+              )
+            }
+          />
         </LoadingBar>
+        <InfraStatuses infras={infras} />
 
         <LogStream ref={this.scrollRef}>
-          <Wrapper>
-            {this.renderLogs()}
-          </Wrapper>
+          <Wrapper>{this.renderLogs()}</Wrapper>
         </LogStream>
 
         <Helper>
@@ -273,7 +305,7 @@ const Log = styled.div`
 
 const LogStream = styled.div`
   height: 300px;
-  margin-top: 30px;
+  margin-top: 20px;
   font-size: 13px;
   border: 2px solid #ffffff55;
   border-radius: 10px;
@@ -292,8 +324,8 @@ const Message = styled.div`
   font-size: 13px;
 `;
 
-const Loaded = styled.div`
-  width: ${(props: { progress: string }) => props.progress};
+const Loaded = styled.div<{ progress: string }>`
+  width: ${props => props.progress};
   height: 100%;
   background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
   background-size: 400% 400%;

+ 20 - 3
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -49,14 +49,31 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         this.props.setWelcome(true);
       } else {
         this.props.setWelcome(false);
-        
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
           let clusters = res.data;
+          clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
             this.setState({ clusters });
-            setCurrentCluster(clusters[0]);
-          } else if (this.props.currentView !== 'provisioner') {
+            let saved = JSON.parse(localStorage.getItem('currentCluster'));
+            if (localStorage.getItem('currentCluster') !== 'null') {
+              setCurrentCluster(clusters[0]);
+              for (let i = 0; i < clusters.length; i++) {
+                if (clusters[i].id = saved.id 
+                  && clusters[i].project_id === saved.project_id 
+                  && clusters[i].name === saved.name
+                ) {
+                  setCurrentCluster(clusters[i]);
+                  break;
+                }
+              }
+            } else {
+              setCurrentCluster(clusters[0]);
+            }
+          } else if (
+            this.props.currentView !== 'provisioner'
+            && this.props.currentView !== 'new-project'
+          ) {
             this.setState({ clusters: [] });
             setCurrentCluster(null);
             this.props.setCurrentView('dashboard');

+ 2 - 0
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -22,6 +22,8 @@ export default class Drawer extends Component<PropsType, StateType> {
     let { currentCluster, setCurrentCluster } = this.context;
 
     if (clusters.length > 0 && currentCluster) {
+      clusters.sort((a, b) => a.id - b.id);
+      
       return clusters.map((cluster: ClusterType, i: number) => {
         /*
         let active = this.context.activeProject &&

+ 5 - 31
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -2,13 +2,12 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
-import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 
 type PropsType = {
   currentProject: ProjectType,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   projects: ProjectType[],
 };
 
@@ -21,40 +20,15 @@ export default class ProjectSection extends Component<PropsType, StateType> {
     expanded: false,
   };
 
-  handleSelectProject = (project: ProjectType) => {
-    this.context.setCurrentProject(project);
-    
-    api.getInfra('<token>', {}, { project_id: project.id }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else if (res.data) {
-
-        let viewData = [] as any[]
-        res.data.forEach((el: InfraType) => {
-          if (el.status === 'creating') {
-            viewData.push({
-              infra_id: el.id,
-              kind: el.kind,
-            });
-          }
-        });
-
-        if (viewData.length > 0) {
-          this.props.setCurrentView('provisioner', viewData);
-        } else {
-          this.props.setCurrentView('dashboard');
-        }
-      }
-    });
-  }
-
   renderOptionList = () => {
+    let { setCurrentProject } = this.context;
+
     return this.props.projects.map((project: ProjectType, i: number) => {
       return (
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => this.handleSelectProject(project)}
+          onClick={() => setCurrentProject(project)}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -170,7 +144,7 @@ const Option = styled.div`
   font-size: 13px;
   align-items: center;
   padding-left: 10px;
-  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};
+  cursor: pointer;
   padding-right: 10px;
   background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
   :hover {

+ 1 - 1
dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx

@@ -5,7 +5,7 @@ import { Context } from '../../../shared/Context';
 import ProjectSection from './ProjectSection';
 
 type PropsType = {
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {

+ 40 - 31
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -3,17 +3,19 @@ import styled from 'styled-components';
 import category from '../../../assets/category.svg';
 import integrations from '../../../assets/integrations.svg';
 import filter from '../../../assets/filter.svg';
+import settings from '../../../assets/settings.svg';
 
 import { Context } from '../../../shared/Context';
 
 import ClusterSection from './ClusterSection';
 import ProjectSectionContainer from './ProjectSectionContainer';
 import loading from '../../../assets/loading.gif';
+import posthog from 'posthog-js';
 
 type PropsType = {
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
-  setCurrentView: (x: string, viewData?: any) => void,
+  setCurrentView: (x: string) => void,
   currentView: string,
   forceRefreshClusters: boolean,
   setRefreshClusters: (x: boolean) => void,
@@ -92,39 +94,46 @@ export default class Sidebar extends Component<PropsType, StateType> {
   };
 
   renderProjectContents = () => {
-    if (this.props.currentView === 'provisioner') {
-      return (
-        <ProjectPlaceholder>
-          <img src={loading} /> Creating . . .
-        </ProjectPlaceholder>
-      )
-    } else if (this.context.currentProject) {
+    let { currentView, setCurrentView } = this.props;
+    let { currentProject, setCurrentModal } = this.context;
+    if (currentProject) {
       return (
         <>
           <SidebarLabel>Home</SidebarLabel>
           <NavButton
-            onClick={() => this.props.setCurrentView('dashboard')}
-            selected={this.props.currentView === 'dashboard'}
+            onClick={() => (currentView !== 'provisioner') && setCurrentView('dashboard')}
+            selected={currentView === 'dashboard' || currentView === 'provisioner'}
           >
-            <img src={category} />
+            <Img src={category} />
             Dashboard
           </NavButton>
           <NavButton
-            onClick={() => this.props.setCurrentView('templates')}
-            selected={this.props.currentView === 'templates'}
+            onClick={() => setCurrentView('templates')}
+            selected={currentView === 'templates'}
           >
-            <img src={filter} />
+            <Img src={filter} />
             Templates
           </NavButton>
           <NavButton
-            selected={this.props.currentView === 'integrations'}
+            selected={currentView === 'integrations'}
             onClick={() => {
-              this.context.setCurrentModal('IntegrationsInstructionsModal', {})
+              setCurrentModal('IntegrationsInstructionsModal', {})
             }}
           >
-            <img src={integrations} />
+            <Img src={integrations} />
             Integrations
           </NavButton>
+          {this.context.currentProject.roles.filter((obj: any) => {
+            return obj.user_id === this.context.user.userId;
+          })[0].kind === 'admin' &&
+            <NavButton
+              onClick={() => this.props.setCurrentView('project-settings')}
+              selected={this.props.currentView === 'project-settings'}
+            >
+              <Img enlarge={true} src={settings} />
+              Settings
+            </NavButton>
+          }
 
           <br />
 
@@ -133,9 +142,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
             forceCloseDrawer={this.state.forceCloseDrawer} 
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
-            currentView={this.props.currentView}
-            setCurrentView={this.props.setCurrentView}
-            isSelected={this.props.currentView === 'cluster-dashboard'}
+            currentView={currentView}
+            setCurrentView={setCurrentView}
+            isSelected={currentView === 'cluster-dashboard'}
             forceRefreshClusters={this.props.forceRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
           />
@@ -154,7 +163,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
   // SidebarBg is separate to cover retracted drawer
   render() {
     return (
-      <div>
+      <>
         {this.renderPullTab()}
         <StyledSidebar showSidebar={this.state.showSidebar}>
           <SidebarBg />
@@ -175,7 +184,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
 
           {this.renderProjectContents()}
         </StyledSidebar>
-      </div>
+      </>
     );
   }
 }
@@ -231,16 +240,16 @@ const NavButton = styled.div`
     left: 19px;
     top: 8px;
   }
+`;
 
-  > img {
-    padding: 4px 4px;
-    height: 23px;
-    width: 23px;
-    border-radius: 3px;
-    position: absolute;
-    left: 20px;
-    top: 9px;
-  }
+const Img = styled.img<{ enlarge?: boolean }>`
+  padding: 4px 4px;
+  height: ${props => props.enlarge ? '27px' : '23px'};
+  width: ${props => props.enlarge ? '27px' : '23px'};
+  border-radius: 3px;
+  position: absolute;
+  left: ${props => props.enlarge ? '19px' : '20px'};
+  top: 9px;
 `;
 
 const BottomSection = styled.div`

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

@@ -49,7 +49,7 @@ class ContextProvider extends Component {
     },
     user: null as any,
     setUser: (userId: number, email: string) => {
-      this.setState({ user: {userId, email} });
+      this.setState({ user: { userId, email } });
     },
     devOpsMode: true,
     setDevOpsMode: (devOpsMode: boolean) => {

+ 54 - 35
dashboard/src/shared/api.tsx

@@ -95,6 +95,10 @@ const getIngress = baseApi<{
   return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 
+const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+});
+
 const getRevisions = baseApi<{
   namespace: string,
   cluster_id: number,
@@ -173,6 +177,10 @@ const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
+const deleteInvite = baseApi<{}, { id: number, invId: number }>('DELETE', pathParams => {
+  return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
+});
+
 const deployTemplate = baseApi<{
   templateName: string,
   imageURL?: string,
@@ -332,54 +340,65 @@ const createGKE = baseApi<{
   return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 
+const createInvite = baseApi<{
+  email: string
+}, {
+  id: number
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+})
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
-  uninstallTemplate,
+  checkAuth,
+  createAWSIntegration,
+  createECR,
+  createGCPIntegration,
   createGCR,
   createGKE,
-  createGCPIntegration,
+  createInvite,
+  createProject,
   deleteCluster,
+  deleteInvite,
+  deleteProject,
+  deployTemplate,
   destroyCluster,
-  getInfra,
-  linkGithubProject,
-  getGitRepos,
-  checkAuth,
-  registerUser,
-  logInUser,
-  logOutUser,
-  getRepos,
-  getUser,
-  updateUser,
-  getClusters,
-  getCharts,
+  getBranchContents,
+  getBranches,
   getChart,
+  getCharts,
   getChartComponents,
   getChartControllers,
-  getNamespaces,
-  getMatchingPods,
-  getIngress,
-  getRevisions,
-  rollbackChart,
-  upgradeChartValues,
-  getTemplates,
-  getTemplateInfo,
-  getBranches,
-  getBranchContents,
-  getProjects,
-  getReleaseToken,
-  createProject,
-  deleteProject,
-  deployTemplate,
   getClusterIntegrations,
-  getRegistryIntegrations,
-  getRepoIntegrations,
+  getClusters,
+  getGitRepos,
+  getImageRepos,
+  getImageTags,
+  getInfra,
+  getIngress,
+  getInvites,
+  getMatchingPods,
+  getNamespaces,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,
-  createAWSIntegration,
+  getProjects,
+  getRegistryIntegrations,
+  getReleaseToken,
+  getRepoIntegrations,
+  getRepos,
+  getRevisions,
+  getTemplateInfo,
+  getTemplates,
+  getUser,
+  linkGithubProject,
+  logInUser,
+  logOutUser,
   provisionECR,
   provisionEKS,
-  createECR,
-  getImageRepos,
-  getImageTags,
+  registerUser,
+  rollbackChart,
+  uninstallTemplate,
+  updateUser,
+  upgradeChartValues,
 }

+ 66 - 0
dashboard/src/shared/common.tsx

@@ -1,6 +1,16 @@
 import aws from '../assets/aws.png';
 import digitalOcean from '../assets/do.png';
 import gcp from '../assets/gcp.png';
+import { InfraType } from '../shared/types';
+
+export const infraNames: any = {
+  'ecr': 'Elastic Container Registry (ECR)',
+  'eks': 'Elastic Kubernetes Service (EKS)',
+  'gcr': 'Google Container Registry (GCR)',
+  'gke': 'Google Kubernetes Engine (GKE)',
+  'docr': 'Digital Ocean Container Registry',
+  'doks': 'Digital Ocean Kubernetes Service'
+};
 
 export const integrationList: any = {
   'kubernetes': {
@@ -56,8 +66,64 @@ export const integrationList: any = {
   }
 };
 
+export const isAlphanumeric = (x: string | null) => {
+  let re = /^[a-z0-9-]+$/;
+  if (!x || x.length == 0 || x.search(re) === -1) {
+    return false;
+  }
+  return true;
+}
+
 export const getIgnoreCase = (object: any, key: string) => {
   return object[Object.keys(object)
     .find(k => k.toLowerCase() === key.toLowerCase())
   ];
+}
+
+export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
+  if (infras.length === 0) {
+    return true;
+  }
+
+  let infraSets = [
+    ['ecr', 'eks'],
+    ['gcr', 'gke'],
+    ['docr', 'doks']
+  ];
+
+  let completed = [] as string[];
+  infras.forEach((infra: InfraType, i: number) => {
+    if (infra.status === 'created') {
+      completed.push(infra.kind);
+    }
+  });
+
+  completed.forEach((kind: string, i: number) => {
+    infraSets.forEach((infraSet: string[], i: number) => {
+      infraSet.includes(kind) && infraSet.splice(infraSet.indexOf(kind), 1);
+    });
+  });
+
+  let anyCompleted = false;
+  infraSets.forEach((infraSet: string[], i: number) => {
+    if (infraSet.length === 0) {
+      anyCompleted = true;
+    }
+  })
+  return anyCompleted;
+}
+
+export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
+  let newestInstances = {} as any;
+  infras.forEach((infra: InfraType, i: number) => {
+    if (!newestInstances[infra.kind]) {
+      newestInstances[infra.kind] = infra;
+    } else {
+      let existingId = newestInstances[infra.kind].id;
+      if (infra.id > existingId) {
+        newestInstances[infra.kind] = infra;
+      }
+    }
+  });
+  return Object.values(newestInstances);
 }

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

@@ -145,7 +145,15 @@ export interface ImageType {
 
 export interface InfraType {
   id: number,
-  project_d: number,
+  project_id: number,
   kind: string,
   status: string,
+}
+
+export interface InviteType {
+  token: string,
+  expired: boolean,
+  email: string,
+  accepted: boolean,
+  id: number,
 }

+ 28 - 0
internal/forms/invite.go

@@ -0,0 +1,28 @@
+package forms
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+// CreateInvite represents the accepted values for creating an
+// invite to a project
+type CreateInvite struct {
+	Email     string `json:"email" form:"required"`
+	ProjectID uint   `form:"required"`
+}
+
+// ToInvite converts the project to a gorm project model
+func (ci *CreateInvite) ToInvite() (*models.Invite, error) {
+	// generate a token and an expiry time
+	expiry := time.Now().Add(24 * time.Hour)
+
+	return &models.Invite{
+		Email:     ci.Email,
+		Expiry:    &expiry,
+		ProjectID: ci.ProjectID,
+		Token:     oauth.CreateRandomState(),
+	}, nil
+}

+ 48 - 0
internal/models/invite.go

@@ -0,0 +1,48 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// Invite type that extends gorm.Model
+type Invite struct {
+	gorm.Model
+
+	Token  string `gorm:"unique"`
+	Expiry *time.Time
+	Email  string
+
+	ProjectID uint
+	UserID    uint
+}
+
+// InviteExternal represents the Invite type that is sent over REST
+type InviteExternal struct {
+	ID       uint   `json:"id"`
+	Token    string `json:"token"`
+	Expired  bool   `json:"expired"`
+	Email    string `json:"email"`
+	Accepted bool   `json:"accepted"`
+}
+
+// Externalize generates an external Invite to be shared over REST
+func (i *Invite) Externalize() *InviteExternal {
+	return &InviteExternal{
+		ID:       i.Model.ID,
+		Token:    i.Token,
+		Email:    i.Email,
+		Expired:  i.IsExpired(),
+		Accepted: i.IsAccepted(),
+	}
+}
+
+func (i *Invite) IsExpired() bool {
+	timeLeft := i.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}
+
+func (i *Invite) IsAccepted() bool {
+	return i.UserID != 0
+}

+ 3 - 0
internal/models/project.go

@@ -26,6 +26,9 @@ type Project struct {
 	// linked helm repos
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
+	// invitations to the project
+	Invites []Invite `json:"invites"`
+
 	// provisioned aws infra
 	Infras []Infra `json:"infras"`
 

+ 28 - 0
internal/repository/gorm/helpers_test.go

@@ -3,6 +3,7 @@ package gorm_test
 import (
 	"os"
 	"testing"
+	"time"
 
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
@@ -23,6 +24,7 @@ type tester struct {
 	initClusters []*models.Cluster
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
+	initInvites  []*models.Invite
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
 	initBasics   []*ints.BasicIntegration
@@ -58,6 +60,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -457,3 +460,28 @@ func initInfra(tester *tester, t *testing.T) {
 
 	tester.initInfras = append(tester.initInfras, infra)
 }
+
+func initInvite(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "testing@test.it",
+		ProjectID: 1,
+	}
+
+	invite, err := tester.repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initInvites = append(tester.initInvites, invite)
+}

+ 98 - 0
internal/repository/gorm/invite.go

@@ -0,0 +1,98 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// InviteRepository uses gorm.DB for querying the database
+type InviteRepository struct {
+	db *gorm.DB
+}
+
+// NewInviteRepository returns a InviteRepository which uses
+// gorm.DB for querying the database
+func NewInviteRepository(db *gorm.DB) repository.InviteRepository {
+	return &InviteRepository{db}
+}
+
+// CreateInvite creates a new invite
+func (repo *InviteRepository) CreateInvite(invite *models.Invite) (*models.Invite, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", invite.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("Invites")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(invite); err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ReadInvite gets an invite specified by a unique id
+func (repo *InviteRepository) ReadInvite(id uint) (*models.Invite, error) {
+	invite := &models.Invite{}
+
+	if err := repo.db.Where("id = ?", id).First(&invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ReadInviteByToken gets an invite specified by a unique token
+func (repo *InviteRepository) ReadInviteByToken(token string) (*models.Invite, error) {
+	invite := &models.Invite{}
+
+	if err := repo.db.Where("token = ?", token).First(&invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// ListInvitesByProjectID finds all invites
+// for a given project id
+func (repo *InviteRepository) ListInvitesByProjectID(
+	projectID uint,
+) ([]*models.Invite, error) {
+	invites := []*models.Invite{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&invites).Error; err != nil {
+		return nil, err
+	}
+
+	return invites, nil
+}
+
+// UpdateInvite updates an invitation in the DB
+func (repo *InviteRepository) UpdateInvite(
+	invite *models.Invite,
+) (*models.Invite, error) {
+	if err := repo.db.Save(invite).Error; err != nil {
+		return nil, err
+	}
+
+	return invite, nil
+}
+
+// DeleteInvite removes a registry from the db
+func (repo *InviteRepository) DeleteInvite(
+	invite *models.Invite,
+) error {
+	// clear TokenCache association
+	if err := repo.db.Where("id = ?", invite.ID).Delete(&models.Invite{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}

+ 100 - 0
internal/repository/gorm/invite_test.go

@@ -0,0 +1,100 @@
+package gorm_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateInvite(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_invite.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "testing@test.it",
+		ProjectID: 1,
+	}
+
+	invite, err := tester.repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	invite, err = tester.repo.Invite.ReadInvite(invite.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1, project id is 1 and token is "abcd"
+	if invite.Model.ID != 1 {
+		t.Errorf("incorrect invite ID: expected %d, got %d\n", 1, invite.Model.ID)
+	}
+
+	if invite.ProjectID != 1 {
+		t.Errorf("incorrect invite project ID: expected %d, got %d\n", 1, invite.ProjectID)
+	}
+
+	if invite.Token != "abcd" {
+		t.Errorf("incorrect token: expected %s, got %s\n", "abcd", invite.Token)
+	}
+
+	if invite.Email != "testing@test.it" {
+		t.Errorf("incorrect email: expected %s, got %s\n", "testing@test.it", invite.Email)
+	}
+}
+
+func TestListInvitesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_invites.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initInvite(tester, t)
+	defer cleanup(tester, t)
+
+	invites, err := tester.repo.Invite.ListInvitesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(invites) != 1 {
+		t.Fatalf("length of invites incorrect: expected %d, got %d\n", 1, len(invites))
+	}
+
+	// make sure data is correct
+	expInvite := models.Invite{
+		Token:     "abcd",
+		Email:     "testing@test.it",
+		Expiry:    &time.Time{},
+		ProjectID: 1,
+	}
+
+	invite := invites[0]
+	invite.Expiry = &time.Time{}
+
+	// reset fields for reflect.DeepEqual
+	invite.Model = gorm.Model{}
+
+	if diff := deep.Equal(expInvite, *invite); diff != nil {
+		t.Errorf("incorrect invite")
+		t.Error(diff)
+	}
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -18,6 +18,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
 		Infra:            NewInfraRepository(db, key),
+		Invite:           NewInviteRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 15 - 0
internal/repository/invite.go

@@ -0,0 +1,15 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// InviteRepository represents the set of queries on the Invite model
+type InviteRepository interface {
+	CreateInvite(invite *models.Invite) (*models.Invite, error)
+	ReadInvite(id uint) (*models.Invite, error)
+	ReadInviteByToken(token string) (*models.Invite, error)
+	ListInvitesByProjectID(projectID uint) ([]*models.Invite, error)
+	UpdateInvite(invite *models.Invite) (*models.Invite, error)
+	DeleteInvite(invite *models.Invite) error
+}

+ 124 - 0
internal/repository/memory/invite.go

@@ -0,0 +1,124 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// InviteRepository uses gorm.DB for querying the database
+type InviteRepository struct {
+	canQuery bool
+	invites  []*models.Invite
+}
+
+// NewInviteRepository returns a InviteRepository which uses
+// gorm.DB for querying the database
+func NewInviteRepository(canQuery bool) repository.InviteRepository {
+	return &InviteRepository{canQuery, []*models.Invite{}}
+}
+
+// CreateInvite creates a new invite
+func (repo *InviteRepository) CreateInvite(invite *models.Invite) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.invites = append(repo.invites, invite)
+	invite.ID = uint(len(repo.invites))
+
+	return invite, nil
+}
+
+// ReadInvite gets an invite specified by a unique id
+func (repo *InviteRepository) ReadInvite(id uint) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.invites) || repo.invites[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.invites[index], nil
+}
+
+// ReadInviteByToken gets an invite specified by a unique token
+func (repo *InviteRepository) ReadInviteByToken(token string) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	var res *models.Invite
+
+	for _, invite := range repo.invites {
+		if token == invite.Token {
+			res = invite
+		}
+	}
+
+	if res == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return res, nil
+}
+
+// ListInvitesByProjectID finds all invites
+// for a given project id
+func (repo *InviteRepository) ListInvitesByProjectID(
+	projectID uint,
+) ([]*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.Invite, 0)
+
+	for _, invite := range repo.invites {
+		if invite != nil && invite.ProjectID == projectID {
+			res = append(res, invite)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateInvite updates an invitation in the DB
+func (repo *InviteRepository) UpdateInvite(
+	invite *models.Invite,
+) (*models.Invite, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(invite.ID-1) >= len(repo.invites) || repo.invites[invite.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(invite.ID - 1)
+	repo.invites[index] = invite
+
+	return invite, nil
+}
+
+// DeleteInvite removes a registry from the db
+func (repo *InviteRepository) DeleteInvite(
+	invite *models.Invite,
+) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(invite.ID-1) >= len(repo.invites) || repo.invites[invite.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(invite.ID - 1)
+	repo.invites[index] = nil
+
+	return nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -15,6 +15,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		HelmRepo:         NewHelmRepoRepository(canQuery),
 		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
+		Invite:           NewInviteRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 2 - 1
internal/repository/repository.go

@@ -10,7 +10,8 @@ type Repository struct {
 	Cluster          ClusterRepository
 	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
-	Infra         InfraRepository
+	Infra            InfraRepository
+	Invite           InviteRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 239 - 0
server/api/invite_handler.go

@@ -0,0 +1,239 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleCreateInvite creates a new invite for a project
+func (app *App) HandleCreateInvite(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateInvite{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an invite
+	invite, err := form.ToInvite()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	invite, err = app.Repo.Invite.CreateInvite(invite)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New invite created: %d", invite.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	inviteExt := invite.Externalize()
+
+	if err := json.NewEncoder(w).Encode(inviteExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleAcceptInvite accepts an invite to a new project: if successful, a new role
+// is created for that user in the project
+func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	token := chi.URLParam(r, "token")
+
+	if token == "" {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invite, err := app.Repo.Invite.ReadInviteByToken(token)
+
+	if err != nil || invite.ProjectID != uint(projID) {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Invalid invite token",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	// check that the invite has not expired and has not been accepted
+	if invite.IsExpired() || invite.IsAccepted() {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Invite has expired",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	// check that the invite email matches the user's email
+	if user.Email != invite.Email {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Cannot accept this invite",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	// create a new role for the user in the project
+	projModel, err := app.Repo.Project.ReadProject(uint(projID))
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// create a new Role with the user as the admin
+	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
+		UserID:    userID,
+		ProjectID: uint(projID),
+		Kind:      models.RoleAdmin,
+	})
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// update the invite
+	invite.UserID = userID
+
+	_, err = app.Repo.Invite.UpdateInvite(invite)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+	return
+}
+
+// HandleListProjectInvites returns a list of invites for a project
+func (app *App) HandleListProjectInvites(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invites, err := app.Repo.Invite.ListInvitesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extInvites := make([]*models.InviteExternal, 0)
+
+	for _, invite := range invites {
+		extInvites = append(extInvites, invite.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extInvites); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDeleteProjectInvite handles the deletion of an Invite via the invite ID
+func (app *App) HandleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "invite_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invite, err := app.Repo.Invite.ReadInvite(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	err = app.Repo.Invite.DeleteInvite(invite)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 273 - 0
server/api/invite_handler_test.go

@@ -0,0 +1,273 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type inviteTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *inviteTest, tester *tester, t *testing.T)
+}
+
+func testInviteRequests(t *testing.T, tests []*inviteTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var createInviteTests = []*inviteTest{
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create invite",
+		method:    "POST",
+		endpoint:  "/api/projects/1/invites",
+		body:      `{"email":"test@test.it"}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"expired":false,"email":"test@test.it","accepted":false}`,
+		useCookie: true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){
+			func(c *inviteTest, tester *tester, t *testing.T) {
+				// manually read the invite to get the expected token
+				invite, _ := tester.repo.Invite.ReadInvite(1)
+
+				gotBody := &models.InviteExternal{}
+				expBody := &models.InviteExternal{
+					Token: invite.Token,
+				}
+
+				json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+				json.Unmarshal([]byte(c.expBody), &expBody)
+
+				if diff := deep.Equal(gotBody, expBody); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
+				}
+			},
+		},
+	},
+}
+
+func TestHandleCreateInvite(t *testing.T) {
+	testInviteRequests(t, createInviteTests, true)
+}
+
+var listInvitesTest = []*inviteTest{
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initInvite,
+		},
+		msg:       "List invites",
+		method:    "GET",
+		endpoint:  "/api/projects/1/invites",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"expired":false,"email":"test@test.it","accepted":false}]`,
+		useCookie: true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){
+			func(c *inviteTest, tester *tester, t *testing.T) {
+				// manually read the invite to get the expected token
+				invite, _ := tester.repo.Invite.ReadInvite(1)
+
+				gotBody := []*models.InviteExternal{}
+				expBody := []*models.InviteExternal{}
+
+				json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+				json.Unmarshal([]byte(c.expBody), &expBody)
+
+				expBody[0].Token = invite.Token
+
+				if diff := deep.Equal(gotBody, expBody); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
+				}
+			},
+		},
+	},
+}
+
+func TestHandleListInvites(t *testing.T) {
+	testInviteRequests(t, listInvitesTest, true)
+}
+
+var acceptInviteTests = []*inviteTest{
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initUserAlt,
+			initProject,
+			initInvite,
+		},
+		msg:       "Accept invite",
+		method:    "GET",
+		endpoint:  "/api/projects/1/invites/abcd",
+		body:      ``,
+		expStatus: http.StatusFound,
+		expBody:   ``,
+		useCookie: true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){
+			func(c *inviteTest, tester *tester, t *testing.T) {
+				user, err := tester.repo.User.ReadUserByEmail("test@test.it")
+
+				if err != nil {
+					t.Fatalf("%v\n", err)
+				}
+
+				projects, err := tester.repo.Project.ListProjectsByUserID(user.ID)
+
+				if len(projects) != 1 {
+					t.Fatalf("length of projects not 1\n")
+				}
+
+				if projects[0].ID != 1 {
+					t.Fatalf("project id was not 1\n")
+				}
+
+				if projects[0].Name != "project-test" {
+					t.Fatalf("project was not project-test\n")
+				}
+			},
+		},
+	},
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initUserAlt,
+			initProject,
+			initInvite,
+		},
+		msg:        "Accept invite wrong token",
+		method:     "GET",
+		endpoint:   "/api/projects/1/invites/abcd1",
+		body:       ``,
+		expStatus:  http.StatusForbidden,
+		expBody:    ``,
+		useCookie:  true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){},
+	},
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initInvite,
+		},
+		msg:        "Accept invite wrong user",
+		method:     "GET",
+		endpoint:   "/api/projects/1/invites/abcd",
+		body:       ``,
+		expStatus:  http.StatusForbidden,
+		expBody:    ``,
+		useCookie:  true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){},
+	},
+	&inviteTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initUserAlt,
+			initProject,
+			initInviteExpiredToken,
+		},
+		msg:        "Accept invite expired token",
+		method:     "GET",
+		endpoint:   "/api/projects/1/invites/abcd",
+		body:       ``,
+		expStatus:  http.StatusForbidden,
+		expBody:    ``,
+		useCookie:  true,
+		validators: []func(c *inviteTest, tester *tester, t *testing.T){},
+	},
+}
+
+func TestHandleAcceptInvite(t *testing.T) {
+	testInviteRequests(t, acceptInviteTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initInvite(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	expiry := time.Now().Add(24 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "test@test.it",
+		ProjectID: proj.Model.ID,
+	}
+
+	tester.repo.Invite.CreateInvite(invite)
+}
+
+func initInviteExpiredToken(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	expiry := time.Now().Add(-1 * time.Hour)
+
+	invite := &models.Invite{
+		Token:     "abcd",
+		Expiry:    &expiry,
+		Email:     "belanger@getporter.dev",
+		ProjectID: proj.Model.ID,
+	}
+
+	tester.repo.Invite.CreateInvite(invite)
+}

+ 11 - 1
server/api/user_handler.go

@@ -50,6 +50,11 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 		session.Values["email"] = user.Email
 		session.Save(r, w)
 
+		if val, ok := session.Values["redirect"].(string); ok && val != "" {
+			http.Redirect(w, r, val, 302)
+			return
+		}
+
 		w.WriteHeader(http.StatusCreated)
 
 		if err := app.sendUser(w, user.ID, user.Email); err != nil {
@@ -122,7 +127,12 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		app.Logger.Warn().Err(err)
 	}
 
-	w.WriteHeader(http.StatusOK)
+	if val, ok := session.Values["redirect"].(string); ok && val != "" {
+		http.Redirect(w, r, val, 302)
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
 
 	if err := app.sendUser(w, storedUser.ID, storedUser.Email); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)

+ 4 - 0
server/api/user_handler_test.go

@@ -487,6 +487,10 @@ func initUserDefault(tester *tester) {
 	tester.createUserSession("belanger@getporter.dev", "hello")
 }
 
+func initUserAlt(tester *tester) {
+	tester.createUserSession("test@test.it", "hello")
+}
+
 func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",

+ 124 - 0
server/router/middleware/auth.go

@@ -46,6 +46,31 @@ func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 	})
 }
 
+// BasicAuthenticateWithRedirect checks that a user is logged in, and if they're not, the
+// user is redirected to the login page with the redirect path stored in the session
+func (auth *Auth) BasicAuthenticateWithRedirect(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if auth.isLoggedIn(w, r) {
+			next.ServeHTTP(w, r)
+		} else {
+			session, err := auth.store.Get(r, auth.cookieName)
+
+			if err != nil {
+				http.Redirect(w, r, "/dashboard", 302)
+			}
+
+			// need state parameter to validate when redirected
+			session.Values["redirect"] = r.URL.Path
+			session.Save(r, w)
+
+			http.Redirect(w, r, "/dashboard", 302)
+			return
+		}
+
+		return
+	})
+}
+
 // IDLocation represents the location of the ID to use for authentication
 type IDLocation uint
 
@@ -82,6 +107,10 @@ type bodyInfraID struct {
 	InfraID uint64 `json:"infra_id"`
 }
 
+type bodyInviteID struct {
+	InviteID uint64 `json:"invite_id"`
+}
+
 type bodyAWSIntegrationID struct {
 	AWSIntegrationID uint64 `json:"aws_integration_id"`
 }
@@ -230,6 +259,56 @@ func (auth *Auth) DoesUserHaveClusterAccess(
 	})
 }
 
+// DoesUserHaveInviteAccess looks for a project_id parameter and a
+// invite_id parameter, and verifies that the invite belongs
+// to the project
+func (auth *Auth) DoesUserHaveInviteAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	inviteLoc IDLocation,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		inviteID, err := findInviteIDInRequest(r, inviteLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		// get the service accounts belonging to the project
+		invites, err := auth.repo.Invite.ListInvitesByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, invite := range invites {
+			if invite.ID == uint(inviteID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // DoesUserHaveRegistryAccess looks for a project_id parameter and a
 // registry_id parameter, and verifies that the registry belongs
 // to the project
@@ -706,6 +785,51 @@ func findClusterIDInRequest(r *http.Request, clusterLoc IDLocation) (uint64, err
 	return clusterID, nil
 }
 
+func findInviteIDInRequest(r *http.Request, inviteLoc IDLocation) (uint64, error) {
+	var inviteID uint64
+	var err error
+
+	if inviteLoc == URLParam {
+		inviteID, err = strconv.ParseUint(chi.URLParam(r, "invite_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if inviteLoc == BodyParam {
+		form := &bodyInviteID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		inviteID = form.InviteID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if invStrArr, ok := vals["invite_id"]; ok && len(invStrArr) == 1 {
+			inviteID, err = strconv.ParseUint(invStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("invite id not found")
+		}
+	}
+
+	return inviteID, nil
+}
+
 func findRegistryIDInRequest(r *http.Request, registryLoc IDLocation) (uint64, error) {
 	var regID uint64
 	var err error

+ 43 - 0
server/router/router.go

@@ -193,6 +193,49 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		// /api/projects/{project_id}/invites routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/invites",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateInvite, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/invites",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectInvites, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/invites/{token}",
+			auth.BasicAuthenticateWithRedirect(
+				requestlog.NewHandler(a.HandleAcceptInvite, l),
+			),
+		)
+
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/invites/{invite_id}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveInviteAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectInvite, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/infra routes
 		r.Method(
 			"GET",