Răsfoiți Sursa

Merge pull request #245 from porter-dev/beta.3.integration-frontend

Beta.3.integration frontend
jusrhee 5 ani în urmă
părinte
comite
052d679e76

+ 2 - 1
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -62,7 +62,8 @@ export default class ImageSelector extends Component<PropsType, StateType> {
               if (err) {
                 errors.push(1);
               } else {
-
+                console.log(res.data);
+                res.data.sort((a: any, b: any) => (a.created_at > b.created_at) ? 1 : -1);
                 // Loop over found image repositories
                 let newImg = res.data.map((img: any) => {
                   if (this.props.selectedImageUrl === img.uri) {

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

@@ -77,7 +77,6 @@ const Input = styled.input`
   width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
   color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
   padding: 5px 10px;
-  margin-right: 8px;
   height: 30px;
 `;
 

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

@@ -58,8 +58,12 @@ export default class Login extends Component<PropsType, StateType> {
         if (err) {
           this.context.setCurrentError(err.response.data.errors[0])
         }
-        setUser(res?.data?.id, res?.data?.email)
-        err ? console.log(err.response.data) : authenticate();
+        if (res?.data?.redirect) {
+          window.location.href = res.data.redirect;
+        } else {
+          setUser(res?.data?.id, res?.data?.email)
+          err ? console.log(err.response.data) : authenticate();
+        }
       });
     }
   }

+ 6 - 2
dashboard/src/main/Register.tsx

@@ -60,8 +60,12 @@ export default class Register extends Component<PropsType, StateType> {
         email: email,
         password: password
       }, {}, (err: any, res: any) => {
-        setUser(res?.data?.id, res?.data?.email)
-        err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        if (res?.data?.redirect) {
+          window.location.href = res.data.redirect;
+        } else {
+          setUser(res?.data?.id, res?.data?.email)
+          err ? setCurrentError(err.response.data.errors[0]) : authenticate();
+        }
       });
     } 
   };

+ 47 - 101
dashboard/src/main/home/Home.tsx

@@ -1,7 +1,6 @@
 import React, { Component } from 'react';
 import posthog from 'posthog-js';
 import styled from 'styled-components';
-import ReactModal from 'react-modal';
 
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
@@ -23,6 +22,7 @@ import Navbar from './navbar/Navbar';
 import ProvisionerStatus from './provisioner/ProvisionerStatus';
 import ProjectSettings from './project-settings/ProjectSettings';
 import ConfirmOverlay from '../../components/ConfirmOverlay';
+import Modal from './modals/Modal';
 
 type PropsType = {
   logOut: () => void,
@@ -270,12 +270,6 @@ export default class Home extends Component<PropsType, StateType> {
         );
       } else if (currentView === 'integrations') {
         return <Integrations />;
-      } else if (currentView === 'new-project') {
-        return (
-          <NewProject 
-            setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} 
-          />
-        );
       } else if (currentView === 'provisioner') {
         return (
           <ProvisionerStatus
@@ -293,8 +287,12 @@ export default class Home extends Component<PropsType, StateType> {
           setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       );
-    } else {
-
+    } else if (currentView === 'new-project') {
+      return (
+        <NewProject 
+          setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} 
+        />
+      );
     }
   }
 
@@ -337,7 +335,8 @@ export default class Home extends Component<PropsType, StateType> {
         if (res.data.length > 0) {
           this.context.setCurrentProject(res.data[0]);
         } else {
-          this.context.currentModalData.setCurrentView('new-project');
+          this.context.setCurrentProject(null);
+          this.setState({ currentView: 'new-project' });
         }
         this.context.setCurrentModal(null, null);
       }
@@ -386,40 +385,44 @@ export default class Home extends Component<PropsType, StateType> {
     let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
       <StyledHome>
-        <ReactModal
-          isOpen={currentModal === 'ClusterInstructionsModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={TallModalStyles}
-          ariaHideApp={false}
-        >
-          <ClusterInstructionsModal />
-        </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'UpdateClusterModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={ProjectModalStyles}
-          ariaHideApp={false}
-        >
-          <UpdateClusterModal 
-            setRefreshClusters={(x: boolean) => this.setState({ forceRefreshClusters: x })} 
-          />
-        </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'IntegrationsModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={SmallModalStyles}
-          ariaHideApp={false}
-        >
-          <IntegrationsModal />
-        </ReactModal>
-        <ReactModal
-          isOpen={currentModal === 'IntegrationsInstructionsModal'}
-          onRequestClose={() => setCurrentModal(null, null)}
-          style={TallModalStyles}
-          ariaHideApp={false}
-        >
-          <IntegrationsInstructionsModal />
-        </ReactModal>
+        {currentModal === 'ClusterInstructionsModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='760px'
+            height='650px'
+          >
+            <ClusterInstructionsModal />
+          </Modal>
+        }
+        {currentModal === 'UpdateClusterModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='565px'
+            height='275px'
+          >
+            <UpdateClusterModal 
+              setRefreshClusters={(x: boolean) => this.setState({ forceRefreshClusters: x })} 
+            />
+          </Modal>
+        }
+        {currentModal === 'IntegrationsModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='760px'
+            height='725px'
+          >
+            <IntegrationsModal />
+          </Modal>
+        }
+        {currentModal === 'IntegrationsInstructionsModal' &&
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width='760px'
+            height='650px'
+          >
+            <IntegrationsInstructionsModal />
+          </Modal>
+        }
 
         {this.renderSidebar()}
 
@@ -444,63 +447,6 @@ export default class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-const SmallModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '760px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '425px',
-    top: 'calc(50% - 214px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
-const ProjectModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '565px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '275px',
-    top: 'calc(50% - 160px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
-const TallModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '760px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '650px',
-    top: 'calc(50% - 325px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
 const ViewWrapper = styled.div`
   height: 100%;
   width: 100vw;

+ 11 - 11
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -48,12 +48,8 @@ export default class Dashboard extends Component<PropsType, StateType> {
   }
 
   onShowProjectSettings = () => {
-    let { currentProject, setCurrentModal } = this.context;
     let { setCurrentView } = this.props;
-    setCurrentModal('UpdateProjectModal', { 
-      currentProject: currentProject,
-      setCurrentView: setCurrentView,
-    });
+    setCurrentView('project-settings');
   }
 
   render() {
@@ -73,12 +69,16 @@ export default class Dashboard extends Component<PropsType, StateType> {
               </Overlay>
             </DashboardIcon>
               <Title>{currentProject && currentProject.name}</Title>
-              <i
-                className="material-icons"
-                onClick={onShowProjectSettings}
-              >
-                more_vert
-              </i>
+              {this.context.currentProject.roles.filter((obj: any) => {
+                return obj.user_id === this.context.user.userId;
+              })[0].kind === 'admin' &&
+                <i
+                  className="material-icons"
+                  onClick={onShowProjectSettings}
+                >
+                  more_vert
+                </i>
+              }
             </TitleSection>
 
             <InfoSection>

+ 81 - 0
dashboard/src/main/home/modals/Modal.tsx

@@ -0,0 +1,81 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  onRequestClose: () => void,
+  width?: string,
+  height?: string,
+}
+
+type StateType = {
+}
+
+export default class Modal extends Component<PropsType, StateType> {
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    document.addEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  handleClickOutside = (event: any) => {
+    if (this.wrapperRef && !this.wrapperRef.current.contains(event.target)) {
+      this.props.onRequestClose();
+    }
+  }
+
+  render() {
+    let { width, height } = this.props;
+    return (
+      <Overlay>
+        <StyledModal
+          ref={this.wrapperRef}
+          width={width}
+          height={height}
+        >
+          {this.props.children}
+        </StyledModal>
+      </Overlay>
+    );
+  }
+}
+
+const Overlay = styled.div`
+  position: absolute;
+  margin: 0;
+  padding: 0;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0,0,0,0.6);
+  z-index: 3;
+`;
+
+const StyledModal = styled.div`
+  position: absolute;
+  top: calc(50% - (${(props: { width?: string, height?: string }) => props.height ? props.height : '425px'} / 2));
+  left: calc(50% - (${(props: { width?: string, height?: string }) => props.width ? props.width : '760px'} / 2));
+  display: flex;
+  justify-content: center;
+  width: ${(props: { width?: string, height?: string }) => props.width ? props.width : '760px'};
+  max-width: 80vw;
+  height: ${(props: { width?: string, height?: string }) => props.height ? props.height : '425px'};
+  border-radius: 7px;
+  border: 0;
+  background-color: #202227;
+  overflow: visible;
+  padding: 25px 32px;
+  animation: floatInModal 0.5s 0s;
+  @keyframes floatInModal {
+    from {
+      opacity: 0; transform: translateY(30px);
+    }
+    to {
+      opacity: 1; transform: translateY(0px);
+    }
+  }
+`;

+ 15 - 18
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -2,12 +2,14 @@ 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';
 
+import Loading from '../../../components/Loading';
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+
 type PropsType = {
 }
 
@@ -140,7 +142,7 @@ export default class InviteList extends Component<PropsType, StateType> {
               </MailTd>
               <LinkTd isTop={i === 0}>
               </LinkTd>
-              <Td isTop={i === 0}>
+              <Td isTop={i === 0} invis={true}>
                 <CopyButton
                   onClick={() => this.deleteInvite(i)}
                 >
@@ -225,20 +227,20 @@ export default class InviteList extends Component<PropsType, StateType> {
   render() {
     return (
       <>
-        <Subtitle>Manage Access</Subtitle>
+        <Heading isAtTop={true}>Share Project</Heading>
+        <Helper>Generate a project invite for another admin user:</Helper>
         <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'
+            width='calc(100%)'
+            placeholder='ex: mrp@getporter.dev'
           />
           <InviteButton
             onClick={() => this.validateEmail()}
           >
-            Invite!
+            Create Invite
           </InviteButton>
         </CreateInvite>
         {this.state.invalidEmail &&
@@ -315,11 +317,9 @@ const Rower = styled.div`
 `;
 
 const CreateInvite = styled.div`
-  display: flex;
   flex-direction: row;
-  align-items: flex-end;
-  margin-top: -20px;
-  margin-bottom: 14px;
+  align-items: center;
+  margin-top: -10px;
 `;
 
 const ShareLink = styled.input`
@@ -341,10 +341,6 @@ const ShareLink = styled.input`
   }
 `;
 
-const Spacer = styled.div`
-  height: 24px;
-`;
-
 const Table = styled.table`
   width: 100%;
   border-spacing: 0px;
@@ -353,9 +349,10 @@ const Table = styled.table`
 `;
 
 const Td = styled.td`
+  visibility: ${(props: { isTop: boolean, invis?: boolean }) => props.invis ? 'hidden' : 'visible'};
   white-space: nowrap;
   padding: 20px 0px;
-  border-top: ${(props: {isTop: boolean}) => (props.isTop ? 'none' : '1px solid #ffffff55')};
+  border-top: ${(props: { isTop: boolean, invis?: boolean }) => (props.isTop ? 'none' : '1px solid #ffffff55')};
   &:last-child {
     padding-right: 16px;
   }

+ 29 - 33
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -2,6 +2,7 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import InviteList from './InviteList';
+import TabRegion from '../../../components/TabRegion';
 
 import { Context } from '../../../shared/Context';
 
@@ -11,11 +12,18 @@ type PropsType = {
 
 type StateType = {
   projectName: string,
+  currentTab: string,
 }
 
+const tabOptions = [
+  { value: 'manage-access', label: 'Manage Access' },
+  { value: 'additional-settings', label: 'Additional Settings' }
+];
+
 export default class ProjectSettings extends Component<PropsType, StateType> {
   state = {
     projectName: '',
+    currentTab: 'manage-access',
   }
 
   componentDidMount() {
@@ -23,23 +31,10 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
     this.setState({ projectName: currentProject.name });
   }
 
-  renderTitle = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
-      return (
-        <>
-          <TitleSection>
-            <Title>Project Settings</Title>
-          </TitleSection>
-          <LineBreak />
-        </>
-      );
-    }
-  }
-
-  renderDelete = () => {
-    let { currentProject } = this.context;
-    if (currentProject) {
+  renderTabContents = () => {
+    if (this.state.currentTab === 'manage-access') {
+      return <InviteList />;
+    } else {
       return (
         <>
           <Subtitle>Other Settings</Subtitle>
@@ -48,10 +43,12 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
               Delete this project: 
             </BodyText>
             <DeleteButton
-              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
-                currentProject: currentProject,
-                setCurrentView: this.props.setCurrentView,
-              })}
+              onClick={() => {
+                this.context.setCurrentModal('UpdateProjectModal', {
+                  currentProject: this.context.currentProject,
+                  setCurrentView: this.props.setCurrentView,
+                });
+              }}
             >
               Delete
             </DeleteButton>
@@ -61,20 +58,19 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
     }
   }
 
-  renderContents = () => {
-    return (
-      <ContentHolder>
-          <InviteList />
-          {this.renderDelete()}
-      </ContentHolder>
-    )
-  }
-
   render () {
     return (
       <StyledProjectSettings>
-        {this.renderTitle()}
-        {this.renderContents()}
+        <TitleSection>
+          <Title>Project Settings</Title>
+        </TitleSection>
+        <TabRegion
+          currentTab={this.state.currentTab}
+          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          options={tabOptions}
+        >
+          {this.renderTabContents()}
+        </TabRegion>
       </StyledProjectSettings>
     );
   }
@@ -93,7 +89,7 @@ const Title = styled.div`
 `;
 
 const TitleSection = styled.div`
-  margin-bottom: 20px;
+  margin-bottom: 13px;
   display: flex;
   flex-direction: row;
   align-items: center;

+ 30 - 7
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -7,6 +7,7 @@ import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 
+import SelectRow from '../../../components/values-form/SelectRow';
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
 import Heading from '../../../components/values-form/Heading';
@@ -34,10 +35,33 @@ const provisionOptions = [
   { value: 'eks', label: 'Elastic Kubernetes Service (EKS)' },
 ];
 
+const regionOptions = [
+  { value: 'us-east-1', label: 'US East (N. Virginia) us-east-1' },
+  { value: 'us-east-2', label: 'US East (Ohio) us-east-2' },
+  { value: 'us-west-1', label: 'US West (N. California) us-west-1' },
+  { value: 'us-west-2', label: 'US West (Oregon) us-west-2' },
+  { value: 'af-south-1', label: 'Africa (Cape Town) af-south-1' },
+  { value: 'ap-east-1', label: 'Asia Pacific (Hong Kong)ap-east-1' },
+  { value: 'ap-south-1', label: 'Asia Pacific (Mumbai) ap-south-1' },
+  { value: 'ap-northeast-2', label: 'Asia Pacific (Seoul) ap-northeast-2' },
+  { value: 'ap-southeast-1', label: 'Asia Pacific (Singapore) ap-southeast-1' },
+  { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney) ap-southeast-2' },
+  { value: 'ap-northeast-1', label: 'Asia Pacific (Tokyo) ap-northeast-1' },
+  { value: 'ca-central-1', label: 'Canada (Central) ca-central-1' },
+  { value: 'eu-central-1', label: 'Europe (Frankfurt) eu-central-1' },
+  { value: 'eu-west-1', label: 'Europe (Ireland) eu-west-1' },
+  { value: 'eu-west-2', label: 'Europe (London) eu-west-2' },
+  { value: 'eu-south-1', label: 'Europe (Milan) eu-south-1' },
+  { value: 'eu-west-3', label: 'Europe (Paris) eu-west-3' },
+  { value: 'eu-north-1', label: 'Europe (Stockholm) eu-north-1' },
+  { value: 'me-south-1', label: 'Middle East (Bahrain) me-south-1' },
+  { value: 'sa-east-1', label: 'South America (São Paulo) sa-east-1' },
+];
+
 // TODO: Consolidate across forms w/ HOC
 export default class AWSFormSection extends Component<PropsType, StateType> {
   state = {
-    awsRegion: '',
+    awsRegion: 'us-east-1',
     awsAccessId: '',
     awsSecretKey: '',
     selectedInfras: [...provisionOptions],
@@ -247,14 +271,13 @@ export default class AWSFormSection extends Component<PropsType, StateType> {
               Guide
             </GuideButton>
           </Heading>
-          <InputRow
-            type='text'
+          <SelectRow
+            options={regionOptions}
+            width='100%'
             value={awsRegion}
-            setValue={(x: string) => this.setState({ awsRegion: x })}
+            dropdownMaxHeight='240px'
+            setActiveValue={(x: string) => this.setState({ awsRegion: x })}
             label='📍 AWS Region'
-            placeholder='ex: us-east-2'
-            width='100%'
-            isRequired={true}
           />
           <InputRow
             type='text'

+ 216 - 17
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -2,7 +2,12 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import close from '../../../assets/close.png';
+import { isAlphanumeric } from '../../../shared/common';
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType, InfraType } from '../../../shared/types';
 
+import SelectRow from '../../../components/values-form/SelectRow';
 import InputRow from '../../../components/values-form/InputRow';
 import Helper from '../../../components/values-form/Helper';
 import Heading from '../../../components/values-form/Heading';
@@ -11,6 +16,10 @@ import CheckboxList from '../../../components/values-form/CheckboxList';
 
 type PropsType = {
   setSelectedProvisioner: (x: string | null) => void,
+  handleError: () => void,
+  projectName: string,
+  setCurrentView: (x: string | null, data?: any) => void,
+  infras: InfraType[],
 };
 
 type StateType = {
@@ -18,19 +27,202 @@ type StateType = {
   gcpProjectId: string,
   gcpKeyData: string,
   selectedInfras: { value: string, label: string }[],
+  buttonStatus: string,
 };
 
-const dummyOptions = [
+const provisionOptions = [
   { value: 'gcr', label: 'Google Container Registry (GCR)' },
   { value: 'gke', label: 'Googke Kubernetes Engine (GKE)' },
 ];
 
+const regionOptions = [
+  { value: 'us-east-1', label: 'US East (N. Virginia) us-east-1' },
+  { value: 'us-east-2', label: 'US East (Ohio) us-east-2' },
+  { value: 'us-west-1', label: 'US West (N. California) us-west-1' },
+  { value: 'us-west-2', label: 'US West (Oregon) us-west-2' },
+  { value: 'af-south-1', label: 'Africa (Cape Town) af-south-1' },
+  { value: 'ap-east-1', label: 'Asia Pacific (Hong Kong)ap-east-1' },
+  { value: 'ap-south-1', label: 'Asia Pacific (Mumbai) ap-south-1' },
+  { value: 'ap-northeast-2', label: 'Asia Pacific (Seoul) ap-northeast-2' },
+  { value: 'ap-southeast-1', label: 'Asia Pacific (Singapore) ap-southeast-1' },
+  { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney) ap-southeast-2' },
+  { value: 'ap-northeast-1', label: 'Asia Pacific (Tokyo) ap-northeast-1' },
+  { value: 'ca-central-1', label: 'Canada (Central) ca-central-1' },
+  { value: 'eu-central-1', label: 'Europe (Frankfurt) eu-central-1' },
+  { value: 'eu-west-1', label: 'Europe (Ireland) eu-west-1' },
+  { value: 'eu-west-2', label: 'Europe (London) eu-west-2' },
+  { value: 'eu-south-1', label: 'Europe (Milan) eu-south-1' },
+  { value: 'eu-west-3', label: 'Europe (Paris) eu-west-3' },
+  { value: 'eu-north-1', label: 'Europe (Stockholm) eu-north-1' },
+  { value: 'me-south-1', label: 'Middle East (Bahrain) me-south-1' },
+  { value: 'sa-east-1', label: 'South America (São Paulo) sa-east-1' },
+];
+
+// TODO: Consolidate across forms w/ HOC
 export default class GCPFormSection extends Component<PropsType, StateType> {
   state = {
-    gcpRegion: '',
+    gcpRegion: 'us-east-1',
     gcpProjectId: '',
     gcpKeyData: '',
-    selectedInfras: [] as { value: string, label: string }[],
+    selectedInfras: [...provisionOptions],
+    buttonStatus: '',
+  }
+
+  componentDidMount = () => {
+    let { infras } = this.props;
+    let { selectedInfras } = this.state;
+
+    if (infras) {
+      
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      infras.forEach(
+        (infra: InfraType, i: number) => {
+          let { kind, status } = infra;
+          if (status === 'creating' || status === 'created') {
+            filtered = filtered.filter((item: any) => {
+              return item.value !== kind;
+            });
+          }
+        }
+      );
+      this.setState({ selectedInfras: filtered });
+    }
+  }
+
+  checkFormDisabled = () => {
+    let { 
+      gcpRegion,
+      gcpProjectId, 
+      gcpKeyData, 
+      selectedInfras,
+    } = this.state;
+    let { projectName } = this.props;
+    if (projectName || projectName === '') {
+      return (
+        !isAlphanumeric(projectName) 
+          || !(gcpProjectId !== '' && gcpKeyData !== '' && gcpRegion !== '')
+          || selectedInfras.length === 0
+      );
+    } else {
+      return (
+        !(gcpProjectId !== '' && gcpKeyData !== '' && gcpRegion !== '')
+          || selectedInfras.length === 0
+      );
+    }
+  }
+
+  // Step 1: Create a project
+  createProject = (callback?: any) => {
+    console.log('Creating project');
+    let { projectName, handleError } = this.props;
+    let { 
+      user, 
+      setProjects, 
+      setCurrentProject, 
+    } = this.context;
+
+    api.createProject('<token>', { name: projectName }, {
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      } else {
+        let proj = res.data;
+
+        // Need to set project list for dropdown
+        // TODO: consolidate into ProjectSection (case on exists in list on set)
+        api.getProjects('<token>', {}, { 
+          id: user.userId 
+        }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+            handleError();
+            return;
+          }
+          setProjects(res.data);
+          setCurrentProject(proj);
+          callback && callback();
+        });
+      }
+    });
+  }
+
+  provisionGCR = (id: number, callback?: any) => {
+    console.log('Provisioning GCR')
+    let { currentProject } = this.context;
+    let { handleError } = this.props;
+
+    api.createGCR('<token>', {
+      gcp_integration_id: id,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      callback && callback();
+    });
+  }
+
+  provisionGKE = (id: number) => {
+    console.log('Provisioning GKE');
+    let { setCurrentView, handleError } = this.props;
+    let { currentProject } = this.context;
+
+    let clusterName = `${currentProject.name}-cluster`
+    api.createGKE('<token>', {
+      gke_name: clusterName,
+      gcp_integration_id: id,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        handleError();
+        return;
+      }
+      setCurrentView('provisioner');
+    })
+  }
+
+  handleCreateFlow = () => {
+    let { setCurrentView } = this.props;
+    let { selectedInfras, gcpKeyData, gcpProjectId, gcpRegion } = this.state;
+    let { currentProject } = this.context;
+    api.createGCPIntegration('<token>', {
+      gcp_region: gcpRegion,
+      gcp_key_data: gcpKeyData,
+      gcp_project_id: gcpProjectId,
+    }, { project_id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcp provisioned with response: ', res.data);
+        let { id } = res.data;
+
+        if (selectedInfras.length === 2) {
+          // Case: project exists, provision GCR + GKE
+          this.provisionGCR(id, () => this.provisionGKE(id));
+        } else if (selectedInfras[0].value === 'gcr') {
+          // Case: project exists, only provision GCR
+          this.provisionGCR(id, () => setCurrentView('provisioner'));
+        } else {
+          // Case: project exists, only provision GKE
+          this.provisionGKE(id);
+        }
+      }
+    });
+  }
+
+  // TODO: handle generically (with > 2 steps)
+  onCreateGCP = () => {
+    let { projectName } = this.props;
+
+    if (!projectName) {
+      this.handleCreateFlow();
+    } else {
+      this.createProject(this.handleCreateFlow);
+    }
   }
 
   render() {
@@ -51,28 +243,27 @@ export default class GCPFormSection extends Component<PropsType, StateType> {
           <Heading isAtTop={true}>
             GCP Credentials
             <GuideButton 
-              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' 
+              href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' 
               target='_blank'
             >
               <i className="material-icons-outlined">help</i> 
               Guide
             </GuideButton>
           </Heading>
-          <InputRow
-            type='text'
+          <SelectRow
+            options={regionOptions}
+            width='100%'
             value={gcpRegion}
-            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            dropdownMaxHeight='240px'
+            setActiveValue={(x: string) => this.setState({ gcpRegion: x })}
             label='📍 GCP Region'
-            placeholder='ex: us-central1-a'
-            width='100%'
-            isRequired={true}
           />
           <InputRow
             type='text'
             value={gcpProjectId}
             setValue={(x: string) => this.setState({ gcpProjectId: x })}
             label='🏷️ GCP Project ID'
-            placeholder='ex: pale-moon-24601'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
             width='100%'
             isRequired={true}
           />
@@ -86,20 +277,21 @@ export default class GCPFormSection extends Component<PropsType, StateType> {
             isRequired={true}
           />
           <Br />
-          <Heading>Resources</Heading>
-          <Helper>Porter will provision the following resources</Helper>
+          <Heading>GCP Resources</Heading>
+          <Helper>Porter will provision the following GCP resources</Helper>
           <CheckboxList
-            options={dummyOptions}
+            options={provisionOptions}
             selected={selectedInfras}
             setSelected={(x: { value: string, label: string }[]) => {
               this.setState({ selectedInfras: x });
             }}
           />
         </FormSection>
+        {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text='Submit'
-          disabled={true}
-          onClick={() => console.log('oolala')}
+          disabled={this.checkFormDisabled()}
+          onClick={this.onCreateGCP}
           makeFlush={true}
           helper='Note: Provisioning can take up to 15 minutes'
         />
@@ -108,6 +300,12 @@ export default class GCPFormSection extends Component<PropsType, StateType> {
   }
 }
 
+GCPFormSection.contextType = Context;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 2px;
@@ -115,7 +313,7 @@ const Br = styled.div`
 
 const StyledGCPFormSection = styled.div`
   position: relative;
-  padding-bottom: 70px;
+  padding-bottom: 35px;
 `;
 
 const FormSection = styled.div`
@@ -123,6 +321,7 @@ const FormSection = styled.div`
   margin-top: 25px;
   background: #26282f;
   border-radius: 5px;
+  margin-bottom: 25px;
   padding: 25px;
   padding-bottom: 16px;
   font-size: 13px;

+ 7 - 1
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -95,10 +95,16 @@ export default class NewProject extends Component<PropsType, StateType> {
       case 'gcp':
         return (
           <GCPFormSection 
+            handleError={this.handleError}
+            projectName={projectName}
+            infras={infras}
+            setCurrentView={setCurrentView}
             setSelectedProvisioner={(x: string | null) => {
               this.setState({ selectedProvider: x });
             }}
-          />
+          >
+            {renderSkipHelper()}
+          </GCPFormSection>
         );
       case 'do':
         return (

+ 1 - 0
dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -67,6 +67,7 @@ export default class ProvisionerStatus extends Component<PropsType, StateType> {
         console.log(err);
       } 
       let infras = filterOldInfras(res.data);
+      console.log('filtered infras: ', infras);
       let error = false;
 
       let maxStep = {} as Record<string, number>

+ 24 - 7
dashboard/src/shared/common.tsx

@@ -80,17 +80,17 @@ export const getIgnoreCase = (object: any, key: string) => {
   ];
 }
 
+const infraSets = [
+  ['ecr', 'eks'],
+  ['gcr', 'gke'],
+  ['docr', 'doks']
+];
+
 export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
   if (infras.length === 0) {
     return false;
   }
 
-  let infraSets = [
-    ['ecr', 'eks'],
-    ['gcr', 'gke'],
-    ['docr', 'doks']
-  ];
-
   let completed = [] as string[];
   infras.forEach((infra: InfraType, i: number) => {
     if (infra.status === 'created') {
@@ -115,7 +115,18 @@ export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
 
 export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
   let newestInstances = {} as any;
+  let newestId = -1;
+  let whitelistedInfras = [] as string[];
   infras.forEach((infra: InfraType, i: number) => {
+
+    // Determine the most recent set for which provisioning was attempted
+    if (infra.id > newestId) {
+      newestId = infra.id;
+      infraSets.forEach((infraSet: string[]) => {
+        infraSet.includes(infra.kind) ? whitelistedInfras = infraSet : null;
+      });
+    }
+
     if (!newestInstances[infra.kind]) {
       newestInstances[infra.kind] = infra;
     } else {
@@ -125,5 +136,11 @@ export const filterOldInfras = (infras: InfraType[]): InfraType[] => {
       }
     }
   });
-  return Object.values(newestInstances);
+
+  let newestInfras = Object.values(newestInstances) as InfraType[];
+  let result = newestInfras.filter((x: InfraType) => {
+    return whitelistedInfras.includes(x.kind)
+  });
+  console.log('filtered infras (helper internal): ', result);
+  return result;
 }

+ 26 - 20
server/api/user_handler.go

@@ -45,7 +45,11 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
-		redirect := session.Values["redirect"]
+		var redirect string
+
+		if valR := session.Values["redirect"]; valR != nil {
+			redirect = session.Values["redirect"].(string)
+		}
 
 		session.Values["authenticated"] = true
 		session.Values["user_id"] = user.ID
@@ -53,14 +57,9 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 		session.Values["redirect"] = ""
 		session.Save(r, w)
 
-		if val, ok := redirect.(string); ok && val != "" {
-			http.Redirect(w, r, val, 302)
-			return
-		}
-
 		w.WriteHeader(http.StatusCreated)
 
-		if err := app.sendUser(w, user.ID, user.Email); err != nil {
+		if err := app.sendUser(w, user.ID, user.Email, redirect); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
@@ -80,7 +79,7 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 	email, _ := session.Values["email"].(string)
 	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, userID, email); err != nil {
+	if err := app.sendUser(w, userID, email, ""); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -122,7 +121,11 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	redirect := session.Values["redirect"]
+	var redirect string
+
+	if valR := session.Values["redirect"]; valR != nil {
+		redirect = session.Values["redirect"].(string)
+	}
 
 	// Set user as authenticated
 	session.Values["authenticated"] = true
@@ -134,14 +137,9 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		app.Logger.Warn().Err(err)
 	}
 
-	if val, ok := redirect.(string); ok && val != "" {
-		http.Redirect(w, r, val, 302)
-		return
-	}
-
-	w.WriteHeader(http.StatusCreated)
+	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, storedUser.ID, storedUser.Email); err != nil {
+	if err := app.sendUser(w, storedUser.ID, storedUser.Email, redirect); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -342,11 +340,19 @@ func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
 	return nil
 }
 
-func (app *App) sendUser(w http.ResponseWriter, userID uint, email string) error {
-	resUser := &models.UserExternal{
-		ID:    userID,
-		Email: email,
+type SendUserExt struct {
+	ID       uint   `json:"id"`
+	Email    string `json:"email"`
+	Redirect string `json:"redirect,omitempty"`
+}
+
+func (app *App) sendUser(w http.ResponseWriter, userID uint, email, redirect string) error {
+	resUser := &SendUserExt{
+		ID:       userID,
+		Email:    email,
+		Redirect: redirect,
 	}
+
 	if err := json.NewEncoder(w).Encode(resUser); err != nil {
 		return err
 	}