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

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

Beta.3.integration frontend
jusrhee 5 лет назад
Родитель
Сommit
a049a8d12f

+ 5 - 5
dashboard/src/components/Selector.tsx

@@ -78,7 +78,7 @@ export default class Selector extends Component<PropsType, StateType> {
   render() {
     let { activeValue } = this.props;
     return (
-      <StyledSelector>
+      <StyledSelector width={this.props.width}>
         <MainSelector
           onClick={() => this.setState({ expanded: !this.state.expanded })}
           expanded={this.state.expanded}
@@ -100,7 +100,7 @@ const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  z-index: 999;
+  z-index: 0;
 `;
 
 const DropdownLabel = styled.div`
@@ -146,7 +146,7 @@ const Dropdown = styled.div`
   top: calc(100% + 5px);
   background: #26282f;
   width: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownWidth};
-  max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownMaxHeight ? props.dropdownMaxHeight : '300px'};
+  max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownMaxHeight || '300px'};
   border-radius: 3px;
   z-index: 999;
   overflow-y: auto;
@@ -154,8 +154,9 @@ const Dropdown = styled.div`
   box-shadow: 0 8px 20px 0px #00000088;
 `;
 
-const StyledSelector = styled.div`
+const StyledSelector = styled.div<{ width: string }>`
   position: relative;
+  width: ${props => props.width};
 `;
 
 const MainSelector = styled.div`
@@ -165,7 +166,6 @@ const MainSelector = styled.div`
   font-size: 13px;
   padding: 5px 10px;
   padding-left: 12px;
-  border-radius: 3px;
   display: flex;
   align-items: center;
   justify-content: space-between;

+ 6 - 4
dashboard/src/components/values-form/SelectRow.tsx

@@ -8,7 +8,9 @@ type PropsType = {
   value: string,
   setActiveValue: (x: string) => void,
   options: { value: string, label: string }[],
-  dropdownLabel?: string
+  dropdownLabel?: string,
+  width?: string,
+  dropdownMaxHeight?: string,
 };
 
 type StateType = {
@@ -25,8 +27,9 @@ export default class SelectRow extends Component<PropsType, StateType> {
             setActiveValue={this.props.setActiveValue}
             options={this.props.options}
             dropdownLabel={this.props.dropdownLabel}
-            width='270px'
-            dropdownMaxHeight={'210px'}
+            width={this.props.width || '270px'}
+            dropdownWidth={this.props.width}
+            dropdownMaxHeight={this.props.dropdownMaxHeight}
           />
         </SelectWrapper>
       </StyledSelectRow>
@@ -35,7 +38,6 @@ export default class SelectRow extends Component<PropsType, StateType> {
 }
 
 const SelectWrapper = styled.div`
-  display: flex;
 `;
 
 const Label = styled.div`

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

@@ -56,10 +56,10 @@ export default class Login extends Component<PropsType, StateType> {
       }, {}, (err: any, res: any) => {
         // TODO: case and set credential error
         if (err) {
-          this.context.setCurrentError('Incorrect email or password.')
+          this.context.setCurrentError(err.response.data.errors[0])
         }
         setUser(res?.data?.id, res?.data?.email)
-        err ? console.log(err) : authenticate();
+        err ? console.log(err.response.data) : authenticate();
       });
     }
   }

+ 156 - 37
dashboard/src/main/home/Home.tsx

@@ -34,6 +34,7 @@ type StateType = {
   forceSidebar: boolean,
   showWelcome: boolean,
   currentView: string,
+  handleDO: boolean, // Trigger DO infra calls after oauth flow if needed
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
   // Track last project id for refreshing clusters on project change
@@ -50,13 +51,15 @@ export default class Home extends Component<PropsType, StateType> {
     prevProjectId: null as number | null,
     forceRefreshClusters: false,
     sidebarReady: false,
+    handleDO: false,
   }
 
+  // TODO: Refactor and prevent flash + multiple reload
   initializeView = () => {
     let { currentProject } = this.props;
-    
     if (currentProject) {
       let { currentCluster } = this.context;
+      
       // Check if current project is provisioning
       api.getInfra('<token>', {}, { project_id: currentProject.id }, (err: any, res: any) => {
         if (err) {
@@ -64,7 +67,7 @@ export default class Home extends Component<PropsType, StateType> {
           return;
         }
         
-        if (!currentCluster && !includesCompletedInfraSet(res.data)) {
+        if (res.data.length > 0 && !(currentCluster || includesCompletedInfraSet(res.data))) {
           this.setState({ currentView: 'provisioner', sidebarReady: true, });
         } else {
           this.setState({ currentView: 'dashboard', sidebarReady: true });
@@ -73,7 +76,7 @@ export default class Home extends Component<PropsType, StateType> {
     }
   }
 
-  getProjects = () => {
+  getProjects = (id?: number) => {
     let { user, setProjects } = this.context;
     let { currentProject } = this.props;
     api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
@@ -84,27 +87,137 @@ export default class Home extends Component<PropsType, StateType> {
           this.setState({ currentView: 'new-project', sidebarReady: true, });
         } else if (res.data.length > 0 && !currentProject) {
           setProjects(res.data);
-          this.context.setCurrentProject(res.data[0]);
 
-          this.initializeView();
+          let foundProject = null;
+          if (id) {
+            res.data.forEach((project: ProjectType, i: number) => {
+              if (project.id === id) {
+                foundProject = project;
+              } 
+            });
+            this.context.setCurrentProject(foundProject);
+            this.setState({ currentView: 'provisioner' });
+          }
+
+          if (!foundProject) {
+            this.context.setCurrentProject(res.data[0]);
+            this.initializeView();
+          }
         }
       }
     });
   }
 
+  provisionDOCR = (integrationId: number, tier: string, callback?: any) => {
+    console.log('Provisioning DOCR...');
+    api.createDOCR('<token>', {
+      do_integration_id: integrationId,
+      docr_name: this.props.currentProject.name,
+      docr_subscription_tier: tier,
+    }, { 
+      project_id: this.props.currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+      callback && callback();
+    });
+  }
+
+  provisionDOKS = (integrationId: number, region: string) => {
+    console.log('Provisioning DOKS...');
+    api.createDOKS('<token>', {
+      do_integration_id: integrationId,
+      doks_name: this.props.currentProject.name,
+      do_region: region,
+    }, { 
+      project_id: this.props.currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        return;
+      }
+      this.setState({ currentView: 'provisioner' });
+    });
+  }
+
+  checkDO = () => {
+    let { currentProject } = this.props;
+    if (this.state.handleDO && currentProject?.id) {
+      api.getOAuthIds('<token>', {}, { 
+        project_id: currentProject.id
+      }, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+          return;
+        }
+        let tgtIntegration = res.data.find((integration: any) => {
+          return integration.client === 'do'
+        });
+        let queryString = window.location.search;
+        let urlParams = new URLSearchParams(queryString);
+        let tier = urlParams.get('tier');
+        let region = urlParams.get('region');
+        let infras = urlParams.getAll('infras');
+        if (infras.length === 2) {
+          this.provisionDOCR(tgtIntegration.id, tier, () => {
+            this.provisionDOKS(tgtIntegration.id, region);
+          });
+        } else if (infras[0] === 'docr') {
+          this.provisionDOCR(tgtIntegration.id, tier, () => {
+            this.setState({ currentView: 'provisioner' });
+          });
+        } else {
+          this.provisionDOKS(tgtIntegration.id, region);
+        }
+      });
+      this.setState({ handleDO: false });
+    }
+  }
+
   componentDidMount() {
+
+    // Handle redirect from DO
+    let queryString = window.location.search;
+    let urlParams = new URLSearchParams(queryString);
+
+    let err = urlParams.get('error');
+    if (err) {
+      this.context.setCurrentError(err);
+    }
+
+    let provision = urlParams.get('provision');
+    let defaultProjectId = null;
+    if (provision === 'do') {
+      defaultProjectId = parseInt(urlParams.get('project_id'));
+      this.setState({ handleDO: true });
+      this.checkDO();
+    }
+    
     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: any) { posthog.identify(user.email) }
     })
 
-    this.getProjects();
+    this.getProjects(defaultProjectId);
   }
 
+  // TODO: Need to handle the following cases. Do a deep rearchitecture (Prov -> Dashboard?) if need be:
+  // 1. Make sure clicking cluster in course drawer shows cluster-dashboard
+  // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
+  // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps.currentProject !== this.props.currentProject) {
-      this.initializeView();
+    if (
+      prevProps.currentProject !== this.props.currentProject
+      || (!prevProps.currentCluster && this.props.currentCluster)
+    ) {
+      if (this.state.handleDO) {
+        this.checkDO();
+      } else {
+        this.initializeView();
+      }
     }
   }
 
@@ -142,41 +255,47 @@ export default class Home extends Component<PropsType, StateType> {
   }
 
   renderContents = () => {
-    let { currentView } = this.state;
-    if (currentView === 'cluster-dashboard') {
-      return this.renderDashboard();
-    } else if (currentView === 'dashboard') {
-      return (
-        <DashboardWrapper>
-          <Dashboard 
-            setCurrentView={(x: string) => this.setState({ currentView: x })}
-            projectId={this.context.currentProject?.id}
+    let { currentView, handleDO } = this.state;
+    if (this.context.currentProject) {
+      if (currentView === 'cluster-dashboard') {
+        return this.renderDashboard();
+      } else if (currentView === 'dashboard') {
+        return (
+          <DashboardWrapper>
+            <Dashboard 
+              setCurrentView={(x: string) => this.setState({ currentView: x })}
+              projectId={this.context.currentProject?.id}
+            />
+          </DashboardWrapper>
+        );
+      } else if (currentView === 'integrations') {
+        return <Integrations />;
+      } else if (currentView === 'new-project') {
+        return (
+          <NewProject 
+            setCurrentView={(x: string, data: any ) => this.setState({ currentView: x })} 
           />
-        </DashboardWrapper>
-      );
-    } 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') {
+        );
+      } else if (currentView === 'provisioner') {
+        return (
+          <ProvisionerStatus
+            setCurrentView={(x: string) => this.setState({ currentView: x })} 
+          />
+        );
+      } else if (currentView === 'project-settings') {
+        return (
+          <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
+        )
+      }
+
       return (
-        <ProvisionerStatus
+        <Templates
           setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       );
-    } else if (currentView === 'project-settings') {
-      return (
-        <ProjectSettings  setCurrentView={(x: string) => this.setState({ currentView: x })} />
-      )
-    }
+    } else {
 
-    return (
-      <Templates
-        setCurrentView={(x: string) => this.setState({ currentView: x })}
-      />
-    );
+    }
   }
 
   setCurrentView = (x: string) => {

+ 56 - 10
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -7,7 +7,7 @@ import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { ProjectType, InfraType } from '../../../shared/types';
 
-import InputRow from '../../../components/values-form/InputRow';
+import SelectRow from '../../../components/values-form/SelectRow';
 import Helper from '../../../components/values-form/Helper';
 import Heading from '../../../components/values-form/Heading';
 import SaveButton from '../../../components/SaveButton';
@@ -22,6 +22,8 @@ type PropsType = {
 
 type StateType = {
   selectedInfras: { value: string, label: string }[],
+  subscriptionTier: string,
+  doRegion: string,
 };
 
 const provisionOptions = [
@@ -29,10 +31,31 @@ const provisionOptions = [
   { value: 'doks', label: 'Digital Ocean Kubernetes Service' },
 ];
 
+const tierOptions = [
+  { value: 'basic', label: 'Basic' },
+  { value: 'starter', label: 'Starter' },
+  { value: 'professional', label: 'Professional' },
+];
+
+const regionOptions = [
+  { value: 'ams3', label: 'Amsterdam 3' },
+  { value: 'blr1', label: 'Bangalore 1' },
+  { value: 'fra1', label: 'Frankfurt 1' },
+  { value: 'lon1', label: 'London 1' },
+  { value: 'nyc1', label: 'New York 1' },
+  { value: 'nyc3', label: 'New York 3' },
+  { value: 'sfo2', label: 'San Francisco 2' },
+  { value: 'sfo3', label: 'San Francisco 3' },
+  { value: 'sgp1', label: 'Singapore 1' },
+  { value: 'tor1', label: 'Toronto 1' },
+];
+
 // TODO: Consolidate across forms w/ HOC
 export default class DOFormSection extends Component<PropsType, StateType> {
   state = {
     selectedInfras: [...provisionOptions],
+    subscriptionTier: 'starter',
+    doRegion: 'nyc1',
   }
 
   componentDidMount = () => {
@@ -100,12 +123,22 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           }
           setProjects(res.data);
           setCurrentProject(proj);
-          callback && callback();
+          callback && callback(proj.id);
         });
       }
     });
   }
 
+  doRedirect = (projectId: number) => {
+    let { subscriptionTier, doRegion, selectedInfras } = this.state;
+    let redirectUrl = `/api/oauth/projects/${projectId}/digitalocean?project_id=${projectId}&provision=do`;
+    redirectUrl += `&tier=${subscriptionTier}&region=${doRegion}`;
+    selectedInfras.forEach((option: { value: string, label: string }) => {
+      redirectUrl += `&infras=${option.value}`;
+    });
+    window.location.href = redirectUrl;
+  }
+
   // TODO: handle generically (with > 2 steps)
   onCreateDO = () => {
     let { projectName } = this.props;
@@ -113,19 +146,15 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     let { currentProject } = this.context;
 
     if (!projectName) {
-      window.location.href = `/api/oauth/projects/${currentProject.id}/digitalocean`;
+      this.doRedirect(currentProject.id);
     } else {
-      this.createProject(() => {
-        window.location.href = `/api/oauth/projects/${currentProject.id}/digitalocean`;
-      });
+      this.createProject((projectId: number) => this.doRedirect(projectId));
     }
   }
 
   render() {
     let { setSelectedProvisioner } = this.props;
-    let {
-      selectedInfras,
-    } = this.state;
+    let { selectedInfras, subscriptionTier, doRegion } = this.state;
 
     return (
       <StyledAWSFormSection>
@@ -133,7 +162,24 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           <CloseButton onClick={() => setSelectedProvisioner(null)}>
             <CloseButtonImg src={close} />
           </CloseButton>
-          <Heading isAtTop={true}>DigitalOcean Resources</Heading>
+          <Heading isAtTop={true}>DigitalOcean Settings</Heading>
+          <SelectRow
+            options={tierOptions}
+            width='100%'
+            value={subscriptionTier}
+            setActiveValue={(x: string) => this.setState({ subscriptionTier: x })}
+            label='💰 Subscription Tier'
+          />
+          <SelectRow
+            options={regionOptions}
+            width='100%'
+            dropdownMaxHeight='240px'
+            value={doRegion}
+            setActiveValue={(x: string) => this.setState({ doRegion: x })}
+            label='📍 DigitalOcean Region'
+          />
+          <Br />
+          <Heading>DigitalOcean Resources</Heading>
           <Helper>Porter will provision the following DigitalOcean resources</Helper>
           <CheckboxList
             options={provisionOptions}

+ 2 - 2
dashboard/src/main/home/provisioner/ProvisionerStatus.tsx

@@ -34,7 +34,7 @@ const dummyInfras = [
   { kind: 'ecr', status: 'created', id: 2, project_id: 1 },
 ];
 
-export default class Provisioner extends Component<PropsType, StateType> {
+export default class ProvisionerStatus extends Component<PropsType, StateType> {
   state = {
     error: false,
     logs: [] as string[],
@@ -323,7 +323,7 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
 }
 
-Provisioner.contextType = Context;
+ProvisionerStatus.contextType = Context;
 
 const Options = styled.div`
   width: 100%;

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

@@ -51,7 +51,6 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         this.props.setWelcome(false);
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
-          console.log(res.data);
           let clusters = res.data;
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {

+ 31 - 1
dashboard/src/shared/api.tsx

@@ -346,10 +346,40 @@ const createInvite = baseApi<{
   id: number
 }>('POST', pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
-})
+});
+
+const getOAuthIds = baseApi<{
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/integrations/oauth`;
+});
+
+const createDOCR = baseApi<{
+  do_integration_id: number,
+  docr_name: string,
+  docr_subscription_tier: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/docr`;
+});
+
+const createDOKS = baseApi<{
+  do_integration_id: number,
+  doks_name: string,
+  do_region: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/doks`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  createDOKS,
+  createDOCR,
+  getOAuthIds,
   checkAuth,
   createAWSIntegration,
   createECR,

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

@@ -82,7 +82,7 @@ export const getIgnoreCase = (object: any, key: string) => {
 
 export const includesCompletedInfraSet = (infras: InfraType[]): boolean => {
   if (infras.length === 0) {
-    return true;
+    return false;
   }
 
   let infraSets = [

+ 25 - 40
server/api/invite_handler.go

@@ -2,7 +2,9 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"net/url"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -69,7 +71,7 @@ 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)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -78,72 +80,48 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	user, err := app.Repo.User.ReadUser(userID)
 
 	if err != nil {
-		app.handleErrorDataRead(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
 	token := chi.URLParam(r, "token")
 
 	if token == "" {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		acceptInviteError(w, r)
 		return
 	}
 
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		acceptInviteError(w, r)
 		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,
-		)
+		vals := url.Values{}
+		vals.Add("error", "Invalid invite token")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 
 		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,
-		)
+		vals := url.Values{}
+		vals.Add("error", "Invite has expired")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 
 		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,
-		)
+		vals := url.Values{}
+		vals.Add("error", "Wrong email for invite")
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 302)
 
 		return
 	}
@@ -152,7 +130,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	projModel, err := app.Repo.Project.ReadProject(uint(projID))
 
 	if err != nil {
-		app.handleErrorDataWrite(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -164,7 +142,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	})
 
 	if err != nil {
-		app.handleErrorDataWrite(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -174,7 +152,7 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	_, err = app.Repo.Invite.UpdateInvite(invite)
 
 	if err != nil {
-		app.handleErrorDataWrite(err, w)
+		acceptInviteError(w, r)
 		return
 	}
 
@@ -182,6 +160,13 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
+func acceptInviteError(w http.ResponseWriter, r *http.Request) {
+	vals := url.Values{}
+	vals.Add("error", "could not accept invite")
+	http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", vals.Encode()), 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)

+ 9 - 2
server/api/user_handler.go

@@ -45,12 +45,15 @@ 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"]
+
 		session.Values["authenticated"] = true
 		session.Values["user_id"] = user.ID
 		session.Values["email"] = user.Email
+		session.Values["redirect"] = ""
 		session.Save(r, w)
 
-		if val, ok := session.Values["redirect"].(string); ok && val != "" {
+		if val, ok := redirect.(string); ok && val != "" {
 			http.Redirect(w, r, val, 302)
 			return
 		}
@@ -119,15 +122,19 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	redirect := session.Values["redirect"]
+
 	// Set user as authenticated
 	session.Values["authenticated"] = true
 	session.Values["user_id"] = storedUser.ID
 	session.Values["email"] = storedUser.Email
+	session.Values["redirect"] = ""
+
 	if err := session.Save(r, w); err != nil {
 		app.Logger.Warn().Err(err)
 	}
 
-	if val, ok := session.Values["redirect"].(string); ok && val != "" {
+	if val, ok := redirect.(string); ok && val != "" {
 		http.Redirect(w, r, val, 302)
 		return
 	}