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

Merge branch '0.6.0-implement-rbac-settings-invite' of https://github.com/porter-dev/porter into 0.6.0-implement-rbac-settings-invite

merge w/ remote
Alexander Belanger 4 лет назад
Родитель
Сommit
dd62b1b376

+ 11 - 0
dashboard/src/main/home/Home.tsx

@@ -26,6 +26,7 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 
 type PropsType = RouteComponentProps & {
   logOut: () => void;
@@ -521,6 +522,16 @@ class Home extends Component<PropsType, StateType> {
           </Modal>
         )}
 
+        {currentModal === "EditInviteOrCollaboratorModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="250px"
+          >
+            <EditInviteOrCollaboratorModal />
+          </Modal>
+        )}
+
         {this.renderSidebar()}
 
         <ViewWrapper>

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

@@ -154,9 +154,9 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                   </DashboardIcon>
                   <Title>{currentProject && currentProject.name}</Title>
-                  {this.context.currentProject.roles.filter((obj: any) => {
+                  {this.context.currentProject?.roles?.filter((obj: any) => {
                     return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" && (
+                  })[0].kind === "admin" || (
                     <i
                       className="material-icons"
                       onClick={onShowProjectSettings}

+ 176 - 0
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -0,0 +1,176 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import SaveButton from "components/SaveButton";
+import { Context } from "shared/Context";
+import RadioSelector from "components/RadioSelector";
+import api from "shared/api";
+import { setTimeout } from "timers";
+
+const EditCollaboratorModal = () => {
+  const {
+    setCurrentModal,
+    currentModalData: { user, isInvite, refetchCallerData },
+    currentProject: { id: project_id },
+  } = useContext(Context);
+  const [status, setStatus] = useState<undefined | string>();
+  const [selectedRole, setSelectedRole] = useState("");
+  const [roleList, setRoleList] = useState([]);
+
+  useEffect(() => {
+    api
+      .getAvailableRoles("<token>", {}, { project_id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setSelectedRole(user?.kind || "developer");
+      });
+  }, []);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
+  };
+
+  const handleUpdate = () => {
+    if (isInvite) {
+      updateInvite();
+    } else {
+      updateCollaborator();
+    }
+  };
+
+  const updateCollaborator = async () => {
+    setStatus("loading");
+    try {
+      await api.updateCollaborator(
+        "<token>",
+        { kind: selectedRole },
+        { project_id, user_id: user.id }
+      );
+      setStatus("successful");
+      refetchCallerData().then(() => {
+        setTimeout(() => setCurrentModal(null, null), 500);
+      });
+    } catch (error) {
+      setStatus("error");
+    }
+  };
+
+  const updateInvite = async () => {
+    setStatus("loading");
+    try {
+      await api.updateInvite(
+        "<token>",
+        { kind: selectedRole },
+        { project_id, invite_id: user.id }
+      );
+      setStatus("successful");
+      refetchCallerData().then(() => {
+        setTimeout(() => setCurrentModal(null, null), 500);
+      });
+    } catch (error) {
+      setStatus("error");
+    }
+  };
+
+  return (
+    <StyledUpdateProjectModal>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+
+      <ModalTitle>
+        Update {isInvite ? "invite" : "collaborator"} {user?.email}
+      </ModalTitle>
+      <Subtitle>Select the new role for the user</Subtitle>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={selectedRole}
+          setSelected={setSelectedRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
+
+      <SaveButton
+        text={`Update ${isInvite ? "invite" : "collaborator"}`}
+        color="#616FEEcc"
+        onClick={() => handleUpdate()}
+        status={status}
+      />
+    </StyledUpdateProjectModal>
+  );
+};
+
+export default EditCollaboratorModal;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+  margin-top: 25px;
+`;
+
+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 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 129 - 29
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -22,42 +22,103 @@ import RadioSelector from "components/RadioSelector";
 
 type Props = {};
 
+export type Collaborator = {
+  id: string;
+  user_id: string;
+  project_id: string;
+  email: string;
+  kind: string;
+};
+
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, setCurrentModal, user } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
-  const [role, setRole] = useState("viewer");
+  const [role, setRole] = useState("developer");
+  const [roleList, setRoleList] = useState([]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
   useEffect(() => {
-    getInviteData();
-  }, []);
+    api
+      .getAvailableRoles("<token>", {}, { project_id: currentProject.id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setRole("developer");
+      });
+
+    getData();
+  }, [currentProject]);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
+  };
 
-  const getInviteData = () => {
+  const getData = async () => {
     setIsLoading(true);
-
-    api
-      .getInvites(
+    let invites = [];
+    try {
+      const response = await api.getInvites(
         "<token>",
         {},
         {
           id: currentProject.id,
         }
-      )
-      .then((res) => {
-        setInvites(res.data);
-        setIsLoading(false);
-      })
-      .catch((err) => console.log(err));
+      );
+      invites = response.data.filter((i: InviteType) => !i.accepted);
+    } catch (err) {
+      console.log(err);
+    }
+    let collaborators: any = [];
+    try {
+      const response = await api.getCollaborators(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      );
+      collaborators = parseCollaboratorsResponse(response.data);
+    } catch (err) {
+      console.log(err);
+    }
+    setInvites([...invites, ...collaborators]);
+    setIsLoading(false);
+  };
+
+  const parseCollaboratorsResponse = (
+    collaborators: Array<Collaborator>
+  ): Array<InviteType> => {
+    return (
+      collaborators
+        // Parse role id to number
+        .map((c) => ({ ...c, id: Number(c.id) }))
+        // Sort them so the owner will be first allways
+        .sort((curr, prev) => curr.id - prev.id)
+        // Remove the owner from list
+        .slice(1)
+        // Parse the remainings to InviteType
+        .map((c) => ({
+          email: c.email,
+          expired: false,
+          id: Number(c.user_id),
+          kind: c.kind,
+          accepted: true,
+          token: "",
+        }))
+    );
   };
 
   const createInvite = () => {
     api
       .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
       .then(() => {
-        getInviteData();
+        getData();
         setEmail("");
       })
       .catch((err) => console.log(err));
@@ -73,7 +134,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           invId: inviteId,
         }
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
@@ -98,7 +159,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           }
         )
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
@@ -113,6 +174,30 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     createInvite();
   };
 
+  const openEditModal = (user: any) => {
+    if (setCurrentModal) {
+      console.log(user);
+      setCurrentModal("EditInviteOrCollaboratorModal", {
+        user,
+        isInvite: user.status !== "accepted",
+        refetchCallerData: getData,
+      });
+    }
+  };
+
+  const removeCollaborator = (user_id: number) => {
+    try {
+      api.removeCollaborator(
+        "<token>",
+        {},
+        { project_id: currentProject.id, user_id }
+      );
+      getData();
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
   const columns = useMemo<
     Column<{
       email: string;
@@ -181,21 +266,35 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
       {
         id: "edit_action",
         Cell: ({ row }: any) => {
-          if (row.values.status === "accepted") {
-            return <CopyButton>Edit</CopyButton>;
-          }
-          return null;
+          return (
+            <CopyButton
+              invis={row.original.currentUser}
+              onClick={() => openEditModal(row.original)}
+            >
+              Edit
+            </CopyButton>
+          );
         },
       },
       {
         id: "remove_invite_action",
         Cell: ({ row }: any) => {
           if (row.values.status === "accepted") {
-            return <CopyButton invis={true}>Remove</CopyButton>;
+            return (
+              <CopyButton
+                invis={row.original.currentUser}
+                onClick={() => removeCollaborator(row.original.id)}
+              >
+                Remove
+              </CopyButton>
+            );
           }
           return (
             <>
-              <CopyButton onClick={() => deleteInvite(row.values.id)}>
+              <CopyButton
+                invis={row.original.currentUser}
+                onClick={() => deleteInvite(row.original.id)}
+              >
                 Delete Invite
               </CopyButton>
             </>
@@ -218,10 +317,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
     const mappedInviteList = inviteList.map(
       ({ accepted, expired, token, ...rest }) => {
+        const currentUser: boolean = user.email === rest.email;
+        console.log(currentUser, user, rest);
         if (accepted) {
           return {
             status: "accepted",
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
           };
         }
@@ -230,6 +332,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           return {
             status: "expired",
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
           };
         }
@@ -237,13 +340,14 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         return {
           status: "pending",
           invite_link: buildInviteLink(token),
+          currentUser,
           ...rest,
         };
       }
     );
 
     return mappedInviteList || [];
-  }, [invites, currentProject?.id, window?.location?.host, isHTTPS]);
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
   return (
     <>
@@ -263,11 +367,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         <RadioSelector
           selected={role}
           setSelected={setRole}
-          options={[
-            { value: "admin", label: "Admin" },
-            { value: "developer", label: "Developer" },
-            { value: "viewer", label: "Viewer" },
-          ]}
+          options={roleList}
         />
       </RoleSelectorWrapper>
       <ButtonWrapper>

+ 2 - 2
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -240,9 +240,9 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={integrations} />
             Integrations
           </NavButton>
-          {this.context.currentProject.roles.filter((obj: any) => {
+          {this.context.currentProject?.roles?.filter((obj: any) => {
             return obj.user_id === this.context.user.userId;
-          })[0].kind === "admin" && (
+          })[0].kind === "admin" || (
             <NavButton
               onClick={() =>
                 pushFiltered(this.props, "/project-settings", ["project_id"])

+ 37 - 0
dashboard/src/shared/api.tsx

@@ -918,6 +918,38 @@ const stopJob = baseApi<
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
 });
 
+const getAvailableRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/roles`
+);
+
+const updateInvite = baseApi<
+  { kind: string },
+  { project_id: number; invite_id: number }
+>(
+  "POST",
+  ({ project_id, invite_id }) =>
+    `/api/projects/${project_id}/invites/${invite_id}`
+);
+
+const getCollaborators = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/collaborators`
+);
+
+const updateCollaborator = baseApi<
+  { kind: string },
+  { project_id: number; user_id: number }
+>(
+  "POST",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const removeCollaborator = baseApi<{}, { project_id: number; user_id: number }>(
+  "DELETE",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1010,4 +1042,9 @@ export default {
   upgradeChartValues,
   deleteJob,
   stopJob,
+  updateInvite,
+  getAvailableRoles,
+  getCollaborators,
+  updateCollaborator,
+  removeCollaborator,
 };