Răsfoiți Sursa

getting infras from dashboard

jusrhee 5 ani în urmă
părinte
comite
dd333d2784
36 a modificat fișierele cu 2151 adăugiri și 96 ștergeri
  1. 1 0
      cmd/app/main.go
  2. 1 0
      cmd/migrate/main.go
  3. 16 0
      dashboard/src/assets/settings.svg
  4. 1 0
      dashboard/src/components/values-form/CheckboxList.tsx
  5. 3 2
      dashboard/src/main/Login.tsx
  6. 10 2
      dashboard/src/main/home/Home.tsx
  7. 38 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  8. 95 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/ZoomPanel.tsx
  9. 82 54
      dashboard/src/main/home/dashboard/Dashboard.tsx
  10. 227 0
      dashboard/src/main/home/dashboard/DashboardWrapper.tsx
  11. 42 2
      dashboard/src/main/home/integrations/IntegrationList.tsx
  12. 29 3
      dashboard/src/main/home/integrations/Integrations.tsx
  13. 17 7
      dashboard/src/main/home/modals/UpdateProjectModal.tsx
  14. 256 0
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  15. 146 1
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  16. 11 2
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  17. 13 8
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  18. 8 0
      dashboard/src/main/home/sidebar/Sidebar.tsx
  19. 2 1
      dashboard/src/shared/Context.tsx
  20. 28 0
      internal/forms/invite.go
  21. 48 0
      internal/models/invite.go
  22. 3 0
      internal/models/project.go
  23. 28 0
      internal/repository/gorm/helpers_test.go
  24. 98 0
      internal/repository/gorm/invite.go
  25. 100 0
      internal/repository/gorm/invite_test.go
  26. 1 0
      internal/repository/gorm/repository.go
  27. 15 0
      internal/repository/invite.go
  28. 124 0
      internal/repository/memory/invite.go
  29. 1 0
      internal/repository/memory/repository.go
  30. 2 1
      internal/repository/repository.go
  31. 239 0
      server/api/invite_handler.go
  32. 273 0
      server/api/invite_handler_test.go
  33. 22 10
      server/api/user_handler.go
  34. 4 0
      server/api/user_handler_test.go
  35. 124 0
      server/router/middleware/auth.go
  36. 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{},

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

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

@@ -29,6 +29,7 @@ const CheckboxList = ({
           <CheckboxOption 
             isLast={i === options.length - 1}
             onClick={() => onSelectOption(option)}
+            key={i}
           >
             <Checkbox checked={selected.includes(option)}>
               <i className="material-icons">done</i>

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

+ 10 - 2
dashboard/src/main/home/Home.tsx

@@ -20,6 +20,7 @@ import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModa
 import NewProject from './new-project/NewProject';
 import Navbar from './navbar/Navbar';
 import Provisioner from './new-project/Provisioner';
+import ProjectSettings from './project-settings/ProjectSettings';
 import posthog from 'posthog-js';
 
 type PropsType = {
@@ -95,7 +96,7 @@ export default class Home extends Component<PropsType, StateType> {
     let { user } = this.context;
     window.location.href.indexOf('127.0.0.1') === -1 && posthog.init(process.env.POSTHOG_API_KEY, {
       api_host: process.env.POSTHOG_HOST,
-      loaded: function(posthog) { posthog.identify(user.email) }
+      loaded: function(posthog: any) { posthog.identify(user.email) }
     })
 
     this.getProjects();
@@ -154,7 +155,10 @@ 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') {
@@ -170,6 +174,10 @@ export default class Home extends Component<PropsType, StateType> {
           viewData={this.state.viewData}
         />
       );
+    } else if (currentView === 'project-settings') {
+      return (
+        <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
+      )
     }
 
     return (

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

+ 82 - 54
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -1,81 +1,109 @@
-import React, { useContext } from 'react';
+import { render } from '@testing-library/react';
+import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import gradient from '../../../assets/gradient.jpg';
 import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
 
 import ProvisionerSettings from '../provisioner/ProvisionerSettings';
 
 type PropsType = {
   setCurrentView: (x: string) => void,
+  projectId: number | null,
 };
 
-const Dashboard = ({ setCurrentView }: PropsType) => {
+type StateType = {
+};
 
-  // TODO: Use ContextType
-  let { 
-    currentProject,
-    currentCluster, 
-    setCurrentModal 
-  } = useContext(Context) as any;
+export default class Dashboard extends Component<PropsType, StateType> {
+  refreshInfras = () => {
+    api.getInfra('<token>', {}, { 
+      project_id: this.props.projectId,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      } 
+      console.log(res.data);
+    });
+  }
+  
+  componentDidMount() {
+    console.log('mounty')
+    this.refreshInfras();
+  }
 
-  let onShowProjectSettings = () => {
+  componentDidUpdate(prevProps: PropsType) {
+    console.log('washy')
+    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,
     });
   }
 
-  return (
-    <>
-      {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 />
-
-          {(true || !currentCluster) && (
-            <>
+  render() {
+    let { currentProject, currentCluster } = this.context;
+    let { setCurrentView } = this.props;
+    let { onShowProjectSettings } = this;
+    return (
+      <>
+        {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} 
-              />
-            </>
-          )}
-        </DashboardWrapper>
-      )}
-    </>
-  );
+            )}
+            <ProvisionerSettings 
+              setCurrentView={setCurrentView} 
+            />
+          </DashboardWrapper>
+        )}
+      </>
+    );
+  }
 }
-export default Dashboard;
+
+Dashboard.contextType = Context;
 
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;

+ 227 - 0
dashboard/src/main/home/dashboard/DashboardWrapper.tsx

@@ -0,0 +1,227 @@
+import { render } from '@testing-library/react';
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import gradient from '../../../assets/gradient.jpg';
+import { Context } from '../../../shared/Context';
+import api from '../../../shared/api';
+
+import ProvisionerSettings from '../provisioner/ProvisionerSettings';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+  projectId: number | null,
+};
+
+type StateType = {
+};
+
+export default class Dashboard extends Component<PropsType, StateType> {
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.projectId && prevProps.projectId !== this.props.projectId) {
+      api.getInfra('<token>', {}, { 
+        project_id: this.props.projectId,
+      }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        } 
+        console.log(res.data);
+      });
+    }
+  }
+
+  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 { onShowProjectSettings } = this;
+    return (
+      <>
+        {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} 
+            />
+          </DashboardWrapper>
+        )}
+      </>
+    );
+  }
+}
+
+Dashboard.contextType = Context;
+
+const DashboardWrapper = styled.div`
+  padding-bottom: 100px;
+`;
+
+const Banner = styled.div`
+  height: 40px;
+  width: 100%;
+  margin: 10px 0 30px;
+  font-size: 13px;
+  display: flex;
+  border-radius: 5px;
+  padding-left: 15px;
+  align-items: center;
+  background: #616FEEcc;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #ffffff;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7A838F;
+  font-size: 13px;
+  > i {
+    color: #8B949F;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const Overlay = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+`;
+
+const DashboardImage = styled.img`
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 18px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 0px;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;

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

+ 17 - 7
dashboard/src/main/home/modals/UpdateProjectModal.tsx

@@ -16,6 +16,8 @@ type PropsType = {
 
 type StateType = {
   projectName: string,
+  textValue: string,
+  valid: boolean,
   status: string | null,
   showDeleteOverlay: boolean
 };
@@ -23,6 +25,8 @@ type StateType = {
 export default class UpdateProjectModal extends Component<PropsType, StateType> {
   state = {
     projectName: this.context.currentModalData.currentProject.name,
+    textValue: '',
+    valid: false,
     status: null as string | null,
     showDeleteOverlay: false,
   };
@@ -95,9 +99,9 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
           <CloseButtonImg src={close} />
         </CloseButton>
 
-        <ModalTitle>Project Settings</ModalTitle>
+        <ModalTitle>Delete Project</ModalTitle>
         <Subtitle>
-          Project name
+          Type {this.state.projectName} to delete.
         </Subtitle>
 
         <InputWrapper>
@@ -106,11 +110,17 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
             <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
           </ProjectIcon>
           <InputRow
-            disabled={true}
+            disabled={false}
             type='string'
-            value={this.state.projectName}
-            setValue={(x: string) => this.setState({ projectName: x })}
-            placeholder='ex: perspective-vortex'
+            value={this.state.textValue}
+            setValue={(x: string) => this.setState({ textValue: x }, () => {
+              if (this.state.textValue === this.state.projectName) {
+                this.setState({ valid: true });
+              } else {
+                this.setState({ valid: false });
+              }
+            })}
+            placeholder={this.state.projectName}
             width='470px'
           />
         </InputWrapper>
@@ -128,7 +138,7 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
         <SaveButton
           text='Delete Project'
           color='#b91133'
-          onClick={() => this.setState({ showDeleteOverlay: true })}
+          onClick={() => {if (this.state.valid) {this.setState({ showDeleteOverlay: true })}}}
           status={this.state.status}
         />
 

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

@@ -0,0 +1,256 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import gradient from '../../../assets/gradient.jpg';
+
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+}
+
+type StateType = {
+  inviteLink: string,
+}
+
+export default class ProjectSettings extends Component<PropsType, StateType> {
+  state = {
+    inviteLink: 'https://asdjfijawioejfialawe.awef.awejiofawjefkajweilfjioawjfli/ajfwieofjaiowejfklajwle/fjawieofaw',
+  }
+
+  renderTitle = () => {
+    let { currentProject } = this.context;
+    if (currentProject) {
+      return (
+        <>
+          <TitleSection>
+            <DashboardIcon>
+              <DashboardImage src={gradient} />
+              <Overlay>{currentProject.name[0].toUpperCase()}</Overlay>
+            </DashboardIcon>
+            <Title>{currentProject.name} Settings</Title>
+          </TitleSection>
+          <LineBreak />
+        </>
+      );
+    }
+  }
+
+  copyToClip = () => {
+    navigator.clipboard.writeText(this.state.inviteLink).then(function() {
+    }, function() {
+      console.log("couldn't copy link to clipboard");
+    })
+  }
+
+  renderCollab = () => {
+    return (
+      <>
+        <Subtitle>Manage Access</Subtitle>
+        <Rower>
+          <ShareLink
+            disabled={true}
+            type='string'
+            value={this.state.inviteLink}
+            placeholder='no link available'
+          />
+          <CopyButton
+            onClick={() => this.copyToClip()}
+          >
+            Copy Link:
+          </CopyButton>
+        </Rower>
+      </>
+    )
+  }
+
+  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>
+          {this.renderCollab()}
+          {this.renderDelete()}
+      </ContentHolder>
+    )
+  }
+
+  render () {
+    return (
+      <StyledProjectSettings>
+        {this.renderTitle()}
+        {this.renderContents()}
+      </StyledProjectSettings>
+    );
+  }
+}
+
+ProjectSettings.contextType = Context;
+
+const Overlay = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+`;
+
+const DashboardImage = styled.img`
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 22px;
+  }
+`;
+
+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;
+  margin-left: 20px;
+`;
+
+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%;
+`;
+
+const Rower = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+`;
+
+const ShareLink = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: 100%;
+  color: #74a5f7;
+  padding: 5px 10px;
+  margin-right: 8px;
+  height: 30px;
+  text-overflow: ellipsis;
+`;

+ 146 - 1
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -4,6 +4,7 @@ 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 } from '../../../shared/types';
 
 import InputRow from '../../../components/values-form/InputRow';
@@ -14,7 +15,9 @@ 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,
 };
 
 type StateType = {
@@ -45,20 +48,160 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
       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 {
@@ -126,7 +269,7 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
         <SaveButton
           text='Submit'
           disabled={this.checkFormDisabled()}
-          onClick={() => console.log('oop')}
+          onClick={this.onCreateAWS}
           makeFlush={true}
           helper='Note: Provisioning can take up to 15 minutes'
         />
@@ -135,6 +278,8 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
   }
 }
 
+AWSFormSection.contextType = Context;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 11 - 2
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -27,6 +27,13 @@ export default class NewProject extends Component<PropsType, StateType> {
     selectedProvider: null as string | null,
   }
 
+  // Handle any submission (pre-status) error
+  handleError = () => {
+    let { setCurrentView } = this.props;
+    setCurrentView('dashboard');
+    this.setState({ selectedProvider: null });
+  }
+
   renderSelectedProvider = () => {
     let { selectedProvider } = this.state;
     let { projectName, setCurrentView } = this.props;
@@ -67,10 +74,12 @@ export default class NewProject extends Component<PropsType, StateType> {
       case 'aws':
         return (
           <AWSFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            setCurrentView={setCurrentView}
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
             }}
-            projectName={projectName}
           >
             {renderSkipHelper()}
           </AWSFormSection>
@@ -103,7 +112,7 @@ export default class NewProject extends Component<PropsType, StateType> {
     return (
       <StyledProvisionerSettings>
         <Helper>
-          Don't have a cluster? Provision through Porter: 
+          Need a cluster? Provision through Porter: 
           {isInNewProject && <Required>*</Required>}
         </Helper>
         {!selectedProvider ? (

+ 13 - 8
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -56,16 +56,21 @@ export default class ClusterSection extends Component<PropsType, StateType> {
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
             this.setState({ clusters });
-            setCurrentCluster(clusters[0]);
-            /*
-            try {
-              setCurrentCluster((localStorage.getItem('currentCluster')) ? 
-                JSON.parse(localStorage.getItem('currentCluster')) : clusters[0]
-              );
-            } catch(err) {
+            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.setState({ clusters: [] });
             setCurrentCluster(null);

+ 8 - 0
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -3,6 +3,7 @@ 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';
 
@@ -126,6 +127,13 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <img src={integrations} />
             Integrations
           </NavButton>
+          <NavButton
+            onClick={() => this.props.setCurrentView('project-settings')}
+            selected={this.props.currentView === 'project-settings'}
+          >
+            <img src={settings} />
+            Settings
+          </NavButton>
 
           <br />
 

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

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

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

+ 22 - 10
server/api/user_handler.go

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

+ 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",