Browse Source

merged in sean invite changes

jusrhee 5 years ago
parent
commit
c6dd5a5f05

+ 63 - 9
dashboard/src/main/home/Home.tsx

@@ -14,7 +14,6 @@ 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';
@@ -23,6 +22,7 @@ import NewProject from './new-project/NewProject';
 import Navbar from './navbar/Navbar';
 import ProvisionerStatus from './provisioner/ProvisionerStatus';
 import ProjectSettings from './project-settings/ProjectSettings';
+import ConfirmOverlay from '../../components/ConfirmOverlay';
 
 type PropsType = {
   logOut: () => void,
@@ -208,6 +208,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 (
@@ -220,14 +275,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)}
@@ -264,6 +311,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>
     );
   }

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

@@ -1,287 +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,
-  textValue: string,
-  valid: boolean,
-  status: string | null,
-  showDeleteOverlay: boolean
-};
-
-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,
-  };
-
-  // 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>Delete Project</ModalTitle>
-        <Subtitle>
-          Type {this.state.projectName} to delete.
-        </Subtitle>
-
-        <InputWrapper>
-          <ProjectIcon>
-            <ProjectImage src={gradient} />
-            <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
-          </ProjectIcon>
-          <InputRow
-            disabled={false}
-            type='string'
-            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>
-
-        <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={() => {if (this.state.valid) {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;
-`;

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

+ 11 - 90
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import gradient from '../../../assets/gradient.jpg';
+import InviteList from './InviteList';
 
 import { Context } from '../../../shared/Context';
 
@@ -10,12 +10,17 @@ type PropsType = {
 }
 
 type StateType = {
-  inviteLink: string,
+  projectName: string,
 }
 
 export default class ProjectSettings extends Component<PropsType, StateType> {
   state = {
-    inviteLink: 'https://asdjfijawioejfialawe.awef.awejiofawjefkajweilfjioawjfli/ajfwieofjaiowejfklajwle/fjawieofaw',
+    projectName: '',
+  }
+
+  componentDidMount() {
+    let { currentProject, user } = this.context;
+    this.setState({ projectName: currentProject.name });
   }
 
   renderTitle = () => {
@@ -24,11 +29,7 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
       return (
         <>
           <TitleSection>
-            <DashboardIcon>
-              <DashboardImage src={gradient} />
-              <Overlay>{currentProject.name[0].toUpperCase()}</Overlay>
-            </DashboardIcon>
-            <Title>{currentProject.name} Settings</Title>
+            <Title>Project Settings</Title>
           </TitleSection>
           <LineBreak />
         </>
@@ -36,34 +37,6 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
     }
   }
 
-  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) {
@@ -91,7 +64,7 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
   renderContents = () => {
     return (
       <ContentHolder>
-          {this.renderCollab()}
+          <InviteList />
           {this.renderDelete()}
       </ContentHolder>
     )
@@ -109,43 +82,6 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
 
 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;
@@ -154,7 +90,6 @@ const Title = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  margin-left: 20px;
 `;
 
 const TitleSection = styled.div`
@@ -232,25 +167,11 @@ const DeleteButton = styled(CopyButton)`
 const ContentHolder = styled.div`
   min-width: 420px;
   width: 100%;
+  margin-bottom: 55px;
 `;
 
 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;
 `;

+ 0 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -49,7 +49,6 @@ 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;

+ 11 - 7
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -123,13 +123,17 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <Img src={integrations} />
             Integrations
           </NavButton>
-          <NavButton
-            onClick={() => setCurrentView('project-settings')}
-            selected={currentView === 'project-settings'}
-          >
-            <Img enlarge={true} src={settings} />
-            Settings
-          </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 />
 

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

@@ -37,7 +37,6 @@ class ContextProvider extends Component {
     },
     currentCluster: null as ClusterType | null,
     setCurrentCluster: (currentCluster: ClusterType) => {
-      localStorage.setItem('currentCluster', JSON.stringify(currentCluster));
       this.setState({ currentCluster });
     },
     currentProject: null as ProjectType | null,

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

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

@@ -148,4 +148,12 @@ export interface InfraType {
   project_id: number,
   kind: string,
   status: string,
+}
+
+export interface InviteType {
+  token: string,
+  expired: boolean,
+  email: string,
+  accepted: boolean,
+  id: number,
 }

+ 38 - 0
docker/nginx_remote.conf

@@ -0,0 +1,38 @@
+events {}
+http {
+    upstream api {
+        server localhost:8081;
+    }
+
+    upstream webpack {
+        server localhost:8082;
+    }
+
+    server {
+        listen 8080;
+        server_name localhost;
+
+        location /api/ {
+            proxy_pass http://api;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+        }
+
+        location / {
+            proxy_pass http://webpack;
+            proxy_pass_header Content-Security-Policy;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+        }
+    }
+
+    client_max_body_size 10M;
+}

+ 1 - 1
internal/config/config.go

@@ -36,7 +36,7 @@ type ServerConf struct {
 
 	DOClientID          string `env:"DO_CLIENT_ID"`
 	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
-	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default-latest"`
+	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 16 - 0
internal/forms/infra.go

@@ -9,6 +9,22 @@ import (
 
 const randCharset string = "abcdefghijklmnopqrstuvwxyz1234567890"
 
+// CreateTestInfra represents the accepted values for creating test
+// infra via the provisioning container
+type CreateTestInfra struct {
+	ProjectID uint `json:"project_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm aws infra model
+func (ce *CreateTestInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:      models.InfraTest,
+		ProjectID: ce.ProjectID,
+		Suffix:    stringWithCharset(6, randCharset),
+		Status:    models.StatusCreating,
+	}, nil
+}
+
 // CreateECRInfra represents the accepted values for creating an
 // ECR infra via the provisioning container
 type CreateECRInfra struct {

+ 43 - 14
internal/kubernetes/agent.go

@@ -393,6 +393,7 @@ func (a *Agent) ProvisionDOCR(
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
+	provImageTag string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -411,13 +412,14 @@ func (a *Agent) ProvisionDOCR(
 
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
-		ID:          id,
-		Name:        fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:        provisioner.DOCR,
-		Operation:   operation,
-		Redis:       redisConf,
-		Postgres:    pgConf,
-		LastApplied: infra.LastApplied,
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
 		DO: &do.Conf{
 			DOToken: tok,
 		},
@@ -441,6 +443,7 @@ func (a *Agent) ProvisionDOKS(
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
+	provImageTag string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -459,13 +462,14 @@ func (a *Agent) ProvisionDOKS(
 
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
-		ID:          id,
-		Name:        fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:        provisioner.DOKS,
-		Operation:   operation,
-		Redis:       redisConf,
-		Postgres:    pgConf,
-		LastApplied: infra.LastApplied,
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		LastApplied:         infra.LastApplied,
+		ProvisionerImageTag: provImageTag,
 		DO: &do.Conf{
 			DOToken: tok,
 		},
@@ -478,6 +482,31 @@ func (a *Agent) ProvisionDOKS(
 	return a.provision(prov, infra, repo)
 }
 
+// ProvisionTest spawns a new provisioning pod that tests provisioning
+func (a *Agent) ProvisionTest(
+	projectID uint,
+	infra *models.Infra,
+	repo repository.Repository,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Operation:           operation,
+		Kind:                provisioner.Test,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
 func (a *Agent) provision(
 	prov *provisioner.Conf,
 	infra *models.Infra,

+ 3 - 0
internal/kubernetes/provisioner/provisioner.go

@@ -26,6 +26,7 @@ type InfraOption string
 
 // The list of infra options
 const (
+	Test InfraOption = "test"
 	ECR  InfraOption = "ecr"
 	EKS  InfraOption = "eks"
 	GCR  InfraOption = "gcr"
@@ -94,6 +95,8 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 	args := make([]string, 0)
 
 	switch conf.Kind {
+	case Test:
+		args = []string{operation, "test", "hello"}
 	case ECR:
 		args = []string{operation, "ecr"}
 

+ 1 - 0
internal/models/infra.go

@@ -25,6 +25,7 @@ type InfraKind string
 
 // The supported infra kinds
 const (
+	InfraTest InfraKind = "test"
 	InfraECR  InfraKind = "ecr"
 	InfraEKS  InfraKind = "eks"
 	InfraGCR  InfraKind = "gcr"

+ 136 - 0
server/api/provision_handler.go

@@ -15,6 +15,138 @@ import (
 	"github.com/porter-dev/porter/internal/adapter"
 )
 
+// HandleProvisionTestInfra will create a test resource by deploying a provisioner
+// container pod
+func (app *App) HandleProvisionTestInfra(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
+	}
+
+	// create a new agent
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	form := &forms.CreateTestInfra{
+		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
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.Infra.CreateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionTest(
+		uint(projID),
+		infra,
+		*app.Repo,
+		provisioner.Apply,
+		&app.DBConf,
+		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
+	)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New test infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDestroyTestInfra destroys test infra
+func (app *App) HandleDestroyTestInfra(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// read infra to get id
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning destruction pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// mark infra for deletion
+	infra.Status = models.StatusDestroying
+	infra, err = app.Repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionTest(
+		infra.ProjectID,
+		infra,
+		*app.Repo,
+		provisioner.Destroy,
+		&app.DBConf,
+		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("Test infra marked for destruction: %d", infra.ID)
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
 func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
@@ -753,6 +885,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 		provisioner.Apply,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
@@ -846,6 +979,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		provisioner.Destroy,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
@@ -931,6 +1065,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 		provisioner.Apply,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
@@ -1024,6 +1159,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		provisioner.Destroy,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {

+ 24 - 0
server/router/router.go

@@ -248,6 +248,16 @@ func New(a *api.App) *chi.Mux {
 		)
 
 		// /api/projects/{project_id}/provision routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/provision/test",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleProvisionTestInfra, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"POST",
 			"/projects/{project_id}/provision/ecr",
@@ -380,6 +390,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/infra/{infra_id}/test/destroy",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveInfraAccess(
+					requestlog.NewHandler(a.HandleDestroyTestInfra, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"POST",
 			"/projects/{project_id}/infra/{infra_id}/eks/destroy",