jusrhee 2 anni fa
parent
commit
d53022cbfb

+ 3 - 0
api/types/project.go

@@ -24,6 +24,7 @@ type ProjectList struct {
 	ValidateApplyV2        bool   `json:"validate_apply_v2"`
 	AdvancedInfraEnabled   bool   `json:"advanced_infra_enabled"`
 	SandboxEnabled         bool   `json:"sandbox_enabled"`
+	AdvancedRbacEnabled    bool   `json:"advanced_rbac_enabled"`
 }
 
 // Project type for entries in api responses for everything other than `GET /projects`
@@ -54,6 +55,7 @@ type Project struct {
 	ManagedDeploymentTargetsEnabled bool    `json:"managed_deployment_targets_enabled"`
 	AdvancedInfraEnabled            bool    `json:"advanced_infra_enabled"`
 	SandboxEnabled                  bool    `json:"sandbox_enabled"`
+	AdvancedRbacEnabled             bool    `json:"advanced_rbac_enabled"`
 }
 
 // FeatureFlags is a struct that contains old feature flag representations
@@ -74,6 +76,7 @@ type FeatureFlags struct {
 	StacksEnabled                   string `json:"stacks_enabled,omitempty"`
 	ValidateApplyV2                 bool   `json:"validate_apply_v2"`
 	ManagedDeploymentTargetsEnabled bool   `json:"managed_deployment_targets_enabled"`
+	AdvancedRbacEnabled             bool   `json:"advanced_rbac_enabled"`
 }
 
 // CreateProjectRequest is a struct that contains the information

+ 9 - 0
dashboard/src/assets/role.svg

@@ -0,0 +1,9 @@
+<svg width="17" height="15" viewBox="0 0 17 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1 14C2.13448 12.084 4.62087 10.7727 8.5 10.7727C12.3791 10.7727 14.8655 12.084 16 14M11.5814 4.12C11.5814 5.84313 10.2018 7.24 8.5 7.24C6.79819 7.24 5.41861 5.84313 5.41861 4.12C5.41861 2.39687 6.79819 1 8.5 1C10.2018 1 11.5814 2.39687 11.5814 4.12Z" stroke="url(#paint0_linear_1586_6)" stroke-width="1.4" stroke-linecap="round"/>
+<defs>
+<linearGradient id="paint0_linear_1586_6" x1="1" y1="1" x2="16" y2="18.5" gradientUnits="userSpaceOnUse">
+<stop stop-color="#F8F8F8"/>
+<stop offset="1" stop-color="#484849"/>
+</linearGradient>
+</defs>
+</svg>

+ 18 - 21
dashboard/src/components/porter/Back.tsx

@@ -1,20 +1,17 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
+import { Link } from "react-router-dom";
 import styled from "styled-components";
 
 import leftArrow from "assets/left-arrow.svg";
-import Text from "./Text";
+
 import Container from "./Container";
-import { Link } from "react-router-dom";
 
 type Props = {
   to?: string;
   onClick?: () => void;
 };
 
-const Back: React.FC<Props> = ({
-  to,
-  onClick,
-}) => {
+const Back: React.FC<Props> = ({ to, onClick }) => {
   return (
     <Container row>
       {to ? (
@@ -57,17 +54,17 @@ const BackLink = styled(Link)`
 `;
 
 const StyledBack = styled.div`
-color: #aaaabb88;
-font-size: 13px;
-margin-bottom: 15px;
-display: flex;
-margin-top: -10px;
-z-index: 999;
-padding: 5px;
-padding-right: 7px;
-border-radius: 5px;
-cursor: pointer;
-:hover {
-  background: #ffffff11;
-}
-`;
+  color: #aaaabb88;
+  font-size: 13px;
+  margin-bottom: 15px;
+  display: flex;
+  margin-top: -10px;
+  z-index: 999;
+  padding: 5px;
+  padding-right: 7px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+  }
+`;

+ 72 - 13
dashboard/src/components/porter/Expandable.tsx

@@ -6,6 +6,7 @@ type Props = {
   children: React.ReactNode;
   style?: React.CSSProperties;
   preExpanded?: boolean;
+  alt?: boolean;
 };
 
 // TODO: support footer for consolidation w/ app services
@@ -13,26 +14,42 @@ const Expandable: React.FC<Props> = ({
   header,
   children,
   style,
-  preExpanded
+  preExpanded,
+  alt,
 }) => {
   const [isExpanded, setIsExpanded] = useState(preExpanded || false);
 
+  if (alt) {
+    return (
+      <StyledExpandable style={style}>
+        <AltHeader
+          isExpanded={isExpanded}
+          onClick={() => {
+            setIsExpanded(!isExpanded);
+          }}
+        >
+          <span className="material-icons dropdown">arrow_drop_down</span>
+          {header}
+        </AltHeader>
+        <AltExpandedContents isExpanded={isExpanded}>
+          {children}
+        </AltExpandedContents>
+      </StyledExpandable>
+    );
+  }
+
   return (
     <StyledExpandable style={style}>
       <Header
         isExpanded={isExpanded}
-        onClick={() => { setIsExpanded(!isExpanded) }}
+        onClick={() => {
+          setIsExpanded(!isExpanded);
+        }}
       >
-        <span className="material-icons dropdown">
-          arrow_drop_down
-        </span>
-        <FullWidth>
-          {header}
-        </FullWidth>
+        <span className="material-icons dropdown">arrow_drop_down</span>
+        <FullWidth>{header}</FullWidth>
       </Header>
-      <ExpandedContents isExpanded={isExpanded}>
-        {children}
-      </ExpandedContents>
+      <ExpandedContents isExpanded={isExpanded}>{children}</ExpandedContents>
     </StyledExpandable>
   );
 };
@@ -42,8 +59,8 @@ export default Expandable;
 const ExpandedContents = styled.div<{ isExpanded: boolean }>`
   transition: all 0.5s;
   overflow: hidden;
-  max-height: ${({ isExpanded }) => isExpanded ? "500px" : "0"};
-  padding: ${({ isExpanded }) => isExpanded ? "20px" : "0"};
+  max-height: ${({ isExpanded }) => (isExpanded ? "500px" : "0")};
+  padding: ${({ isExpanded }) => (isExpanded ? "20px" : "0")};
   border-bottom-left-radius: 5px;
   border-bottom-right-radius: 5px;
   background: ${(props) => props.theme.fg + "66"};
@@ -101,3 +118,45 @@ const Header = styled.div<{ isExpanded: boolean }>`
 const StyledExpandable = styled.div`
   transition: all 0.2s;
 `;
+
+const AltHeader = styled.div<{ isExpanded: boolean }>`
+  transition: all 0.2s;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  color: #aaaabbaa;
+  position: relative;
+  :hover {
+    color: ${(props) => props.theme.text.primary};
+  }
+
+  .dropdown {
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    margin-left: -5px;
+    margin-right: 8px;
+    transform: ${({ isExpanded }) => !isExpanded && "rotate(-90deg)"};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const AltExpandedContents = styled.div<{ isExpanded: boolean }>`
+  transition: all 0.5s;
+  margin-left: 4px;
+  overflow: hidden;
+  max-height: ${({ isExpanded }) => (isExpanded ? "500px" : "0")};
+  padding-top: 10px;
+  padding-left: ${({ isExpanded }) => (isExpanded ? "18px" : "0")};
+  border-left: ${({ isExpanded }) => isExpanded && "1px solid #494b4f"};
+  color: ${(props) => props.theme.text.primary};
+`;

+ 18 - 24
dashboard/src/components/porter/Modal.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState } from "react";
-import styled from "styled-components";
 import { createPortal } from "react-dom";
+import styled from "styled-components";
 
 type Props = {
   closeModal?: () => void;
@@ -8,29 +8,23 @@ type Props = {
   width?: string;
 };
 
-const Modal: React.FC<Props> = ({
-  closeModal,
-  children,
-  width,
-}) => {
+const Modal: React.FC<Props> = ({ closeModal, children, width }) => {
   return (
     <>
-      {
-        createPortal(
-          <ModalWrapper>
-            <ModalBg onClick={closeModal} />
-            <StyledModal width={width}> 
-              {closeModal && (
-                <CloseButton onClick={closeModal}>
-                  <i className="material-icons">close</i>
-                </CloseButton>
-              )}
-              {children}
-            </StyledModal>
-          </ModalWrapper>,
-          document.body
-        )
-      }
+      {createPortal(
+        <ModalWrapper>
+          <ModalBg onClick={closeModal} />
+          <StyledModal width={width}>
+            {closeModal && (
+              <CloseButton onClick={closeModal}>
+                <i className="material-icons">close</i>
+              </CloseButton>
+            )}
+            {children}
+          </StyledModal>
+        </ModalWrapper>,
+        document.body
+      )}
     </>
   );
 };
@@ -103,7 +97,7 @@ const StyledModal = styled.div<{
   border-radius: 10px;
   border: 1px solid #494b4f;
   font-size: 13px;
-  width: ${props => props.width || "600px"};
+  width: ${(props) => props.width || "600px"};
   background: #42444933;
   backdrop-filter: saturate(150%) blur(8px);
 
@@ -118,4 +112,4 @@ const StyledModal = styled.div<{
       transform: translateY(0px);
     }
   }
-`;
+`;

+ 39 - 29
dashboard/src/main/home/app-dashboard/expanded-app/status/LogsModal.tsx

@@ -1,40 +1,50 @@
 import React, { useEffect, useRef } from "react";
+
 import Modal from "components/porter/Modal";
-import TitleSection from "components/TitleSection";
 import Text from "components/porter/Text";
+import TitleSection from "components/TitleSection";
+
 import danger from "assets/danger.svg";
 
+import { type PorterLog } from "../logs/types";
 import ExpandedIncidentLogs from "./ExpandedIncidentLogs";
-import { PorterLog } from "../logs/types";
 
-interface LogsModalProps {
-    logs: PorterLog[];
-    setModalVisible: (x: boolean) => void;
-    logsName: string;
-}
-const LogsModal: React.FC<LogsModalProps> = ({ logs, logsName, setModalVisible }) => {
-    const scrollToBottomRef = useRef<HTMLDivElement>(null);
-    const scrollToBottom = () => {
-        if (scrollToBottomRef.current) {
-            scrollToBottomRef.current.scrollIntoView({
-                behavior: "smooth",
-                block: "end",
-            });
-        }
+type LogsModalProps = {
+  logs: PorterLog[];
+  setModalVisible: (x: boolean) => void;
+  logsName: string;
+};
+const LogsModal: React.FC<LogsModalProps> = ({
+  logs,
+  logsName,
+  setModalVisible,
+}) => {
+  const scrollToBottomRef = useRef<HTMLDivElement>(null);
+  const scrollToBottom = () => {
+    if (scrollToBottomRef.current) {
+      scrollToBottomRef.current.scrollIntoView({
+        behavior: "smooth",
+        block: "end",
+      });
     }
-    useEffect(() => {
-        scrollToBottom();
-    }, [scrollToBottomRef]);
-
+  };
+  useEffect(() => {
+    scrollToBottom();
+  }, [scrollToBottomRef]);
 
-    return (
-        <Modal closeModal={() => setModalVisible(false)} width={"800px"}>
-            <TitleSection icon={danger}>
-                <Text size={16}>Logs for {logsName}</Text>
-            </TitleSection>
-            <ExpandedIncidentLogs logs={logs} />
-        </Modal>
-    );
+  return (
+    <Modal
+      closeModal={() => {
+        setModalVisible(false);
+      }}
+      width={"800px"}
+    >
+      <TitleSection icon={danger}>
+        <Text size={16}>Logs for {logsName}</Text>
+      </TitleSection>
+      <ExpandedIncidentLogs logs={logs} />
+    </Modal>
+  );
 };
 
-export default LogsModal;
+export default LogsModal;

+ 266 - 40
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -1,19 +1,24 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
+import { type Column } from "react-table";
 import styled from "styled-components";
 
-import { type InviteType } from "shared/types";
-import api from "shared/api";
-import { Context } from "shared/Context";
-
-import Loading from "components/Loading";
-import InputRow from "components/form-components/InputRow";
-import Helper from "components/form-components/Helper";
-import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
-import { type Column } from "react-table";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import Loading from "components/Loading";
 import Table from "components/OldTable";
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
 import RadioSelector from "components/RadioSelector";
 
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { type InviteType } from "shared/types";
+
+import PermissionGroup from "./PermissionGroup";
+import RoleModal from "./RoleModal";
+
 type Props = {};
 
 export type Collaborator = {
@@ -24,7 +29,7 @@ export type Collaborator = {
   kind: string;
 };
 
-const InvitePage: React.FunctionComponent<Props> = ({ }) => {
+const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const {
     currentProject,
     setCurrentModal,
@@ -41,6 +46,7 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const [roleList, setRoleList] = useState([]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
+  const [showNewGroupModal, setShowNewGroupModal] = useState(false);
 
   useEffect(() => {
     api
@@ -150,7 +156,9 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
         }
       )
       .then(getData)
-      .catch((err) => { console.log(err); });
+      .catch((err) => {
+        console.log(err);
+      });
   };
 
   const replaceInvite = (
@@ -164,15 +172,16 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
         { email: inviteEmail, kind },
         { id: currentProject.id }
       )
-      .then(async () =>
-        await api.deleteInvite(
-          "<token>",
-          {},
-          {
-            id: currentProject.id,
-            invId: inviteId,
-          }
-        )
+      .then(
+        async () =>
+          await api.deleteInvite(
+            "<token>",
+            {},
+            {
+              id: currentProject.id,
+              invId: inviteId,
+            }
+          )
       )
       .then(getData)
       .catch((err) => {
@@ -188,7 +197,8 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
     const trimmedEmail = email.trim();
     setEmail(trimmedEmail);
 
-    const 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,}))$/;
+    const 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(trimmedEmail.toLowerCase())) {
       setIsInvalidEmail(true);
       return;
@@ -222,13 +232,15 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   };
 
   const columns = useMemo<
-    Array<Column<{
-      email: string;
-      id: number;
-      status: string;
-      invite_link: string;
-      kind: string;
-    }>>
+    Array<
+      Column<{
+        email: string;
+        id: number;
+        status: string;
+        invite_link: string;
+        kind: string;
+      }>
+    >
   >(
     () => [
       {
@@ -258,13 +270,13 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
           if (row.values.status === "expired") {
             return (
               <NewLinkButton
-                onClick={() =>
-                  { replaceInvite(
+                onClick={() => {
+                  replaceInvite(
                     row.values.email,
                     row.original.id,
                     row.values.kind
-                  ); }
-                }
+                  );
+                }}
               >
                 <u>Generate a new link</u>
               </NewLinkButton>
@@ -298,13 +310,17 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
               <Flex>
                 <SettingsButton
                   invis={row.original.currentUser}
-                  onClick={() => { openEditModal(row.original); }}
+                  onClick={() => {
+                    openEditModal(row.original);
+                  }}
                 >
                   <i className="material-icons">more_vert</i>
                 </SettingsButton>
                 <DeleteButton
                   invis={row.original.currentUser}
-                  onClick={() => { removeCollaborator(row.original.id); }}
+                  onClick={() => {
+                    removeCollaborator(row.original.id);
+                  }}
                 >
                   <i className="material-icons">delete</i>
                 </DeleteButton>
@@ -315,13 +331,17 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
             <Flex>
               <SettingsButton
                 invis={row.original.currentUser}
-                onClick={() => { openEditModal(row.original); }}
+                onClick={() => {
+                  openEditModal(row.original);
+                }}
               >
                 <i className="material-icons">more_vert</i>
               </SettingsButton>
               <DeleteButton
                 invis={row.original.currentUser}
-                onClick={() => { deleteInvite(row.original.id); }}
+                onClick={() => {
+                  deleteInvite(row.original.id);
+                }}
               >
                 <i className="material-icons">delete</i>
               </DeleteButton>
@@ -338,7 +358,8 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
     inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
     inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
     const buildInviteLink = (token: string) => `
-      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${currentProject.id
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
+        currentProject.id
       }/invites/${token}
     `;
 
@@ -404,18 +425,212 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   return (
     <>
       <>
+        {currentProject?.advanced_rbac_enabled && (
+          <>
+            <Heading isAtTop={true}>Permission groups</Heading>
+            <Helper>Manage permission groups for your organization.</Helper>
+            <PermissionGroup
+              name="Admin"
+              permissions={{
+                applications: {
+                  read: true,
+                  write: true,
+                  create: true,
+                  delete: true,
+                  tabs: {
+                    notifications: true,
+                    activity: true,
+                    overview: true,
+                    logs: true,
+                    metrics: true,
+                    environment: true,
+                    build_settings: true,
+                    settings: true,
+                  },
+                  actions: {
+                    app_rollbacks: true,
+                  },
+                },
+                datastores: {
+                  read: true,
+                  write: true,
+                  create: true,
+                  delete: true,
+                  tabs: {
+                    connection_info: true,
+                    connected_apps: true,
+                    configuration: true,
+                    settings: true,
+                  },
+                },
+                addOns: {
+                  read: true,
+                  write: true,
+                  create: true,
+                  delete: true,
+                },
+                envGroups: {
+                  read: true,
+                  write: true,
+                  create: true,
+                  delete: true,
+                  tabs: {
+                    environment_variables: true,
+                    synced_applications: true,
+                    settings: true,
+                  },
+                },
+                previewEnvironments: {
+                  read: true,
+                  manage: true,
+                  tabs: {
+                    app_services: true,
+                    environment_variables: true,
+                    required_apps: true,
+                    add_ons: true,
+                  },
+                },
+                integrations: {
+                  read: true,
+                  manage: true,
+                },
+              }}
+            />
+            <PermissionGroup
+              name="Developer"
+              permissions={{
+                applications: {
+                  read: true,
+                  write: true,
+                  tabs: {
+                    notifications: true,
+                    activity: true,
+                    overview: true,
+                    logs: true,
+                    metrics: true,
+                    environment: true,
+                  },
+                  actions: {
+                    app_rollbacks: true,
+                  },
+                },
+                datastores: {
+                  read: true,
+                  write: true,
+                  tabs: {
+                    connection_info: true,
+                    connected_apps: true,
+                    configuration: true,
+                  },
+                },
+                addOns: {
+                  read: true,
+                  write: true,
+                },
+                envGroups: {
+                  read: true,
+                  write: true,
+                  tabs: {
+                    environment_variables: true,
+                    synced_applications: true,
+                  },
+                },
+                previewEnvironments: {
+                  read: true,
+                  tabs: {
+                    app_services: true,
+                    environment_variables: true,
+                    required_apps: true,
+                    add_ons: true,
+                  },
+                },
+                integrations: {
+                  read: true,
+                },
+              }}
+            />
+            <PermissionGroup
+              name="Viewer"
+              permissions={{
+                applications: {
+                  read: true,
+                  tabs: {
+                    notifications: true,
+                    activity: true,
+                    overview: true,
+                    logs: true,
+                    metrics: true,
+                    environment: true,
+                  },
+                },
+                datastores: {
+                  read: true,
+                  tabs: {
+                    connection_info: true,
+                    connected_apps: true,
+                    configuration: true,
+                  },
+                },
+                addOns: {
+                  read: true,
+                },
+                envGroups: {
+                  read: true,
+                  tabs: {
+                    environment_variables: true,
+                    synced_applications: true,
+                  },
+                },
+                previewEnvironments: {
+                  read: true,
+                  tabs: {
+                    app_services: true,
+                    environment_variables: true,
+                    required_apps: true,
+                    add_ons: true,
+                  },
+                },
+                integrations: {
+                  read: true,
+                },
+              }}
+            />
+            <Spacer y={0.4} />
+            <Button
+              alt
+              onClick={() => {
+                setShowNewGroupModal(true);
+              }}
+            >
+              <I className="material-icons">add</I>
+              New group
+            </Button>
+            <Spacer y={1.7} />
+            {showNewGroupModal && (
+              <RoleModal
+                name=""
+                readOnly={false}
+                closeModal={() => {
+                  setShowNewGroupModal(false);
+                }}
+              />
+            )}
+          </>
+        )}
         <Heading isAtTop={true}>Share project</Heading>
         <Helper>Generate a project invite for another user.</Helper>
         <InputRowWrapper>
           <InputRow
             value={email}
             type="text"
-            setValue={(newEmail: string) => { setEmail(newEmail); }}
+            setValue={(newEmail: string) => {
+              setEmail(newEmail);
+            }}
             width="100%"
             placeholder="ex: mrp@porter.run"
           />
         </InputRowWrapper>
-        <Helper>Specify a role for this user.</Helper>
+        <Helper>Specify a project role for this user.</Helper>
         <RoleSelectorWrapper>
           <RadioSelector
             selected={role}
@@ -424,7 +639,12 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
           />
         </RoleSelectorWrapper>
         <ButtonWrapper>
-          <InviteButton disabled={!hasSeats} onClick={() => { validateEmail(); }}>
+          <InviteButton
+            disabled={!hasSeats}
+            onClick={() => {
+              validateEmail();
+            }}
+          >
             Create invite
           </InviteButton>
           {isInvalidEmail && (
@@ -456,12 +676,18 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
           </Placeholder>
         )
       )}
+      <Spacer y={2} />
     </>
   );
 };
 
 export default InvitePage;
 
+const I = styled.i`
+  margin-right: 10px;
+  font-size: 18px;
+`;
+
 const Flex = styled.div`
   display: flex;
   align-items: center;

+ 55 - 0
dashboard/src/main/home/project-settings/PermissionGroup.tsx

@@ -0,0 +1,55 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import role from "assets/role.svg";
+
+import RoleModal from "./RoleModal";
+
+type PermissionGroupProps = {
+  name: string;
+  permissions?: any;
+};
+
+const PermissionGroup: React.FC<PermissionGroupProps> = ({
+  name,
+  permissions,
+}) => {
+  const [showModal, setShowModal] = useState(false);
+
+  return (
+    <>
+      <StyledPermissionGroup
+        onClick={() => {
+          setShowModal(true);
+        }}
+      >
+        {name}
+      </StyledPermissionGroup>
+      {showModal && (
+        <RoleModal
+          name={name}
+          permissions={permissions}
+          readOnly={true}
+          closeModal={() => {
+            setShowModal(false);
+          }}
+        />
+      )}
+    </>
+  );
+};
+
+export default PermissionGroup;
+
+const StyledPermissionGroup = styled.div`
+  display: inline-block;
+  border-radius: 5px;
+  margin-right: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 7px 10px;
+  background: ${({ theme }) => theme.clickable.bg};
+  border: 1px solid ${({ theme }) => theme.border};
+  width: fit-content;
+  margin-bottom: 15px;
+`;

+ 42 - 39
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -1,28 +1,33 @@
 import React, { Component, useContext, useEffect, useState } from "react";
+import _ from "lodash";
+import {
+  withRouter,
+  WithRouterProps,
+  type RouteComponentProps,
+} from "react-router";
 import styled from "styled-components";
 
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import Input from "components/porter/Input";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import TabRegion from "components/TabRegion";
+
+import api from "shared/api";
+import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { isAlphanumeric } from "shared/common";
 import { Context } from "shared/Context";
+import { getQueryParam } from "shared/routing";
 import settingsGrad from "assets/settings-grad.svg";
 
-import InvitePage from "./InviteList";
-import TabRegion from "components/TabRegion";
-import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
-import { getQueryParam } from "shared/routing";
 import APITokensSection from "./APITokensSection";
-import _ from "lodash";
-import Link from "components/porter/Link";
-import Spacer from "components/porter/Spacer";
-import ProjectDeleteConsent from "./ProjectDeleteConsent";
+import InvitePage from "./InviteList";
 import Metadata from "./Metadata";
-import Button from "components/porter/Button";
-import Input from "components/porter/Input";
-import { isAlphanumeric } from "shared/common";
-import api from "shared/api";
-import Error from "components/porter/Error";
+import ProjectDeleteConsent from "./ProjectDeleteConsent";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 type ValidationError = {
@@ -32,7 +37,7 @@ type ValidationError = {
 type StateType = {
   projectName: string;
   currentTab: string;
-  tabOptions: { value: string; label: string }[];
+  tabOptions: Array<{ value: string; label: string }>;
   showCostConfirmModal: boolean;
 };
 
@@ -48,8 +53,7 @@ function ProjectSettings(props: any) {
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
 
   useEffect(() => {
-    const selectedTab =
-      getQueryParam(props, "selected_tab") || "manage-access";
+    const selectedTab = getQueryParam(props, "selected_tab") || "manage-access";
 
     if (currentTab !== selectedTab) {
       setCurrentTab(selectedTab);
@@ -60,12 +64,10 @@ function ProjectSettings(props: any) {
     if (projectName !== currentProject.name) {
       setProjectName(currentProject.name);
     }
-
   }, []);
 
-
   useEffect(() => {
-    let { currentProject } = context;
+    const { currentProject } = context;
     if (projectName !== currentProject.name) {
       setProjectName(currentProject.name);
     }
@@ -99,7 +101,6 @@ function ProjectSettings(props: any) {
       });
     }
 
-
     if (!_.isEqual(tabOpts, tabOptions)) {
       setTabOptions(tabOpts);
     }
@@ -108,7 +109,6 @@ function ProjectSettings(props: any) {
     if (selectedTab && selectedTab !== currentTab) {
       setCurrentTab(selectedTab);
     }
-
   }, [context, projectName, currentTab, props, tabOptions]);
 
   const validateProjectName = (): ValidationError => {
@@ -145,19 +145,19 @@ function ProjectSettings(props: any) {
       await api.renameProject(
         "<token>",
         {
-          name: name,
+          name,
         },
         {
           project_id: context.currentProject.id,
-        })
+        }
+      );
       setButtonStatus("success");
       window.location.reload();
-
     } catch (err) {
-      console.log(err)
+      console.log(err);
       setButtonStatus(<Error message="Unable to rename project" />);
     }
-  }
+  };
 
   const renderTabContents = () => {
     if (!props.isAuthorized("settings", "", ["get", "delete"])) {
@@ -166,9 +166,8 @@ function ProjectSettings(props: any) {
 
     if (currentTab === "manage-access") {
       return <InvitePage />;
-    }
-    else if (currentTab == "metadata") {
-      return <Metadata />
+    } else if (currentTab == "metadata") {
+      return <Metadata />;
     } else if (currentTab === "api-tokens") {
       return <APITokensSection />;
     } else if (currentTab === "billing") {
@@ -188,18 +187,23 @@ function ProjectSettings(props: any) {
     } else {
       return (
         <>
-
           <Heading isAtTop={true}>Rename Project</Heading>
 
-          <Helper color={validateProjectName().hasError ? "#f5cb42" : "#aaaabb"}>
+          <Helper
+            color={validateProjectName().hasError ? "#f5cb42" : "#aaaabb"}
+          >
             (lowercase letters, numbers, and "-" only)
           </Helper>
-          <Input placeholder={"ex: perspective-vortex"} value={name} setValue={setName} width={"500px"}>
-          </Input>
+          <Input
+            placeholder={"ex: perspective-vortex"}
+            value={name}
+            setValue={setName}
+            width={"500px"}
+          ></Input>
           <Spacer y={1} />
           <Button
             onClick={() => {
-              handleNameChange()
+              handleNameChange();
             }}
             status={buttonStatus}
             loadingText={"Updating..."}
@@ -228,7 +232,7 @@ function ProjectSettings(props: any) {
           </DeleteButton>
           <ProjectDeleteConsent
             setShowCostConfirmModal={setShowCostConfirmModal}
-            show={showCostConfirmModal}  // <-- Pass these props
+            show={showCostConfirmModal} // <-- Pass these props
           />
         </>
       );
@@ -254,7 +258,6 @@ function ProjectSettings(props: any) {
   );
 }
 
-
 ProjectSettings.contextType = Context;
 
 export default withRouter(withAuth(ProjectSettings));

+ 427 - 0
dashboard/src/main/home/project-settings/RoleModal.tsx

@@ -0,0 +1,427 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import Container from "components/porter/Container";
+import Expandable from "components/porter/Expandable";
+import Image from "components/porter/Image";
+import Input from "components/porter/Input";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import role from "assets/role.svg";
+
+type RoleModalProps = {
+  readOnly?: boolean;
+  name: string;
+  closeModal: () => void;
+  permissions?: any;
+};
+
+const RoleModal: React.FC<RoleModalProps> = ({
+  name,
+  closeModal,
+  readOnly,
+  permissions,
+}) => {
+  const [inputName, setInputName] = useState(name);
+  const [perms, setPerms] = useState(permissions);
+  return (
+    <Modal closeModal={closeModal} width={"800px"}>
+      <Container row>
+        <Image size={18} src={role} />
+        <Spacer inline x={1} />
+        <Text size={16}>Configure role</Text>
+      </Container>
+      <Spacer y={1} />
+      <Text color="helper">Role name</Text>
+      <Spacer y={0.5} />
+      <Input
+        disabled={readOnly}
+        placeholder="ex: Porter Developer"
+        width="300px"
+        value={inputName}
+        setValue={setInputName}
+      />
+      <Spacer y={1} />
+      <Text color="helper">Manage permissions for this role:</Text>
+      <Spacer y={1} />
+      <ScrollWrapper>
+        <Expandable alt preExpanded header={<>Applications</>}>
+          <Expandable alt preExpanded header={<>All applications</>}>
+            <Checkbox
+              checked={perms?.applications.read}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Read
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.applications.write}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Write
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.applications.create}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Create
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.applications.delete}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Delete
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Expandable alt preExpanded header={<>Tabs</>}>
+              <Checkbox
+                checked={perms?.applications.tabs.notifications}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Notifications
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.activity}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Activity
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.overview}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Overview
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.logs}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Logs
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.metrics}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Metrics
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.environment}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Environment
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.build_settings}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Build settings
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.applications.tabs.settings}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Settings
+              </Checkbox>
+              <Spacer y={0.5} />
+            </Expandable>
+            <Expandable alt preExpanded header={<>Actions</>}>
+              <Checkbox
+                checked={perms?.applications.actions?.app_rollbacks}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                App rollbacks
+              </Checkbox>
+              <Spacer y={0.5} />
+            </Expandable>
+          </Expandable>
+        </Expandable>
+
+        <Expandable alt preExpanded header={<>Datastores</>}>
+          <Expandable alt preExpanded header={<>All datastores</>}>
+            <Checkbox
+              checked={perms?.datastores.read}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Read
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.datastores.write}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Write
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.datastores.create}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Create
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.datastores.delete}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Delete
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Expandable alt preExpanded header={<>Tabs</>}>
+              <Checkbox
+                checked={perms?.datastores.tabs.connection_info}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Connection info
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.datastores.tabs.connected_apps}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Connected apps
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.datastores.tabs.configuration}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Configuration
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.datastores.tabs.settings}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Settings
+              </Checkbox>
+              <Spacer y={0.5} />
+            </Expandable>
+          </Expandable>
+        </Expandable>
+
+        <Expandable alt preExpanded header={<>Add-ons</>}>
+          <Expandable alt preExpanded header={<>All add-ons</>}>
+            <Checkbox
+              checked={perms?.addOns.read}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Read
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.addOns.write}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Write
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.addOns.create}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Create
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.addOns.delete}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Delete
+            </Checkbox>
+            <Spacer y={0.5} />
+          </Expandable>
+        </Expandable>
+
+        <Expandable alt preExpanded header={<>Env groups</>}>
+          <Expandable alt preExpanded header={<>All env groups</>}>
+            <Checkbox
+              checked={perms?.envGroups.read}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Read
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.envGroups.write}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Write
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.envGroups.create}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Create
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.envGroups.delete}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Delete
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Expandable alt preExpanded header={<>Tabs</>}>
+              <Checkbox
+                checked={perms?.envGroups.tabs.environment_variables}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Environment variables
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.envGroups.tabs.synced_applications}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Synced applications
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.envGroups.tabs.settings}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Settings
+              </Checkbox>
+              <Spacer y={0.5} />
+            </Expandable>
+          </Expandable>
+        </Expandable>
+
+        <Expandable alt preExpanded header={<>Preview environments</>}>
+          <Expandable alt preExpanded header={<>All preview environments</>}>
+            <Checkbox
+              checked={perms?.previewEnvironments.read}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Read
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.previewEnvironments.manage}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Manage
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Expandable alt preExpanded header={<>Tabs</>}>
+              <Checkbox
+                checked={perms?.previewEnvironments.tabs.app_services}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                App services
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.previewEnvironments.tabs.environment_variables}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Environment variables
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.previewEnvironments.tabs.required_apps}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Required apps
+              </Checkbox>
+              <Spacer y={0.5} />
+              <Checkbox
+                checked={perms?.previewEnvironments.tabs.add_ons}
+                toggleChecked={() => {}}
+                disabled={readOnly}
+              >
+                Add-ons
+              </Checkbox>
+              <Spacer y={0.5} />
+            </Expandable>
+          </Expandable>
+        </Expandable>
+
+        <Expandable alt preExpanded header={<>Integrations</>}>
+          <Expandable alt preExpanded header={<>All integrations</>}>
+            <Checkbox
+              checked={perms?.integrations.read}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Read
+            </Checkbox>
+            <Spacer y={0.5} />
+            <Checkbox
+              checked={perms?.integrations.manage}
+              toggleChecked={() => {}}
+              disabled={readOnly}
+            >
+              Manage
+            </Checkbox>
+            <Spacer y={0.5} />
+          </Expandable>
+        </Expandable>
+      </ScrollWrapper>
+      {!readOnly && (
+        <>
+          <Spacer y={1} />
+          <Button>Create role</Button>
+        </>
+      )}
+    </Modal>
+  );
+};
+
+export default RoleModal;
+
+const ScrollWrapper = styled.div`
+  overflow-y: auto;
+  max-height: calc(100vh - 360px);
+`;

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

@@ -334,6 +334,7 @@ export type ProjectType = {
   managed_deployment_targets_enabled: boolean;
   aws_ack_auth_enabled: boolean;
   sandbox_enabled: boolean;
+  advanced_rbac_enabled: boolean;
   roles: Array<{
     id: number;
     kind: string;

+ 9 - 0
internal/models/project.go

@@ -81,6 +81,9 @@ const (
 
 	// AdvancedInfraEnabled controls whether a project can use advanced infrastructure settings
 	AdvancedInfraEnabled FeatureFlagLabel = "advanced_infra_enabled"
+
+	// AdvancedRbacEnabled controls whether a project can use advanced rbac settings
+	AdvancedRbacEnabled FeatureFlagLabel = "advanced_rbac_enabled"
 )
 
 // ProjectFeatureFlags keeps track of all project-related feature flags
@@ -107,6 +110,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	ValidateApplyV2:                 true,
 	ManagedDeploymentTargetsEnabled: false,
 	AdvancedInfraEnabled:            false,
+	AdvancedRbacEnabled:             false,
 }
 
 type ProjectPlan string
@@ -201,6 +205,7 @@ type Project struct {
 	EnableSandbox        bool `gorm:"default:false"`
 	EnableReprovision    bool `gorm:"default:false"`
 	AdvancedInfraEnabled bool `gorm:"default:false"`
+	AdvancedRbacEnabled  bool `gorm:"default:false"`
 }
 
 // GetFeatureFlag calls launchdarkly for the specified flag
@@ -249,6 +254,8 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient *
 			return false
 		case "advanced_infra_enabled":
 			return false
+		case "advanced_rbac_enabled":
+			return p.AdvancedRbacEnabled
 		}
 	}
 
@@ -299,6 +306,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		ManagedDeploymentTargetsEnabled: p.GetFeatureFlag(ManagedDeploymentTargetsEnabled, launchDarklyClient),
 		AdvancedInfraEnabled:            p.GetFeatureFlag(AdvancedInfraEnabled, launchDarklyClient),
 		SandboxEnabled:                  p.EnableSandbox,
+		AdvancedRbacEnabled:             p.GetFeatureFlag(AdvancedRbacEnabled, launchDarklyClient),
 	}
 }
 
@@ -334,6 +342,7 @@ func (p *Project) ToProjectListType() *types.ProjectList {
 		ValidateApplyV2:        p.ValidateApplyV2,
 		FullAddOns:             p.FullAddOns,
 		AdvancedInfraEnabled:   p.AdvancedInfraEnabled,
+		AdvancedRbacEnabled:    p.AdvancedRbacEnabled,
 	}
 }