2
0
jnfrati 3 жил өмнө
parent
commit
08da8c8517

+ 30 - 11
dashboard/src/components/SearchSelector.tsx

@@ -1,22 +1,25 @@
 import _ from "lodash";
 import React, { useMemo, useState } from "react";
 import styled from "styled-components";
+import Loading from "./Loading";
 
-type Props = {
-  options: any[];
-  onSelect: (option: any) => void;
+type Props<T = any> = {
+  options: T[];
+  onSelect: (option: T) => void;
   label?: string;
   dropdownLabel?: string;
-  getOptionLabel?: (option: any) => string;
-  filterBy?: ((option: any) => string) | string;
+  getOptionLabel?: (option: T) => string;
+  filterBy?: ((option: T) => string) | string;
   noOptionsText?: string;
   dropdownMaxHeight?: string;
   renderAddButton?: any;
   className?: string;
-  renderOptionIcon?: (option: any) => React.ReactNode;
+  renderOptionIcon?: (option: T) => React.ReactNode;
+  placeholder?: string;
+  showLoading?: boolean;
 };
 
-const SearchSelector = ({
+function SearchSelector<O = any>({
   options,
   onSelect,
   label,
@@ -28,7 +31,9 @@ const SearchSelector = ({
   renderAddButton,
   className,
   renderOptionIcon,
-}: Props) => {
+  placeholder = "Find or add a tag...", // legacy value to not break existing code
+  showLoading = false,
+}: Props<O>) {
   const [isExpanded, setIsExpanded] = useState(false);
   const [filter, setFilter] = useState("");
 
@@ -57,9 +62,22 @@ const SearchSelector = ({
       );
     }
 
-    return options.filter((option) => option.includes(filter));
+    return options.filter((option) =>
+      typeof option === "string" ? option.includes(filter) : true
+    );
   }, [filter, options]);
 
+  if (showLoading) {
+    return (
+      <>
+        {label?.length ? <Label>{label}</Label> : null}
+        <InputWrapper className={className}>
+          <Loading />
+        </InputWrapper>
+      </>
+    );
+  }
+
   return (
     <>
       {label?.length ? <Label>{label}</Label> : null}
@@ -71,7 +89,7 @@ const SearchSelector = ({
       >
         <Input
           value={filter}
-          placeholder="Find or add a tag..."
+          placeholder={placeholder}
           onClick={(e) => {
             setIsExpanded(false);
             e.stopPropagation();
@@ -139,7 +157,7 @@ const SearchSelector = ({
       </InputWrapper>
     </>
   );
-};
+}
 
 export default SearchSelector;
 
@@ -152,6 +170,7 @@ const InputWrapper = styled.div`
   background: #ffffff11;
   position: relative;
   width: 100%;
+  min-height: 37px;
 `;
 
 const Input = styled.input`

+ 7 - 9
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -179,16 +179,14 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                   </DashboardIcon>
                   {currentProject && currentProject.name}
-                  {this.context.currentProject?.roles?.filter((obj: any) => {
+                  // TODO REPLACE WITH IS AUTH HOOK
+                  {/* {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>
-                  )}
+                  })[0].kind === "admin" || ( */}
+                  <i className="material-icons" onClick={onShowProjectSettings}>
+                    more_vert
+                  </i>
+                  {/* )} */}
                 </TitleSection>
                 <Br />
 

+ 1 - 0
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -3,6 +3,7 @@ import { CustomerProvider, PlanSelect } from "@ironplans/react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 
+// @TODO: Deprecated, remove.
 function BillingPage() {
   const [customerToken, setCustomerToken] = useState("");
   const [teamID, setTeamID] = useState("");

+ 96 - 92
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -9,30 +9,50 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
+import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam } from "shared/routing";
-import BillingPage from "./BillingPage";
 import APITokensSection from "./APITokensSection";
+import { RolesAdmin } from "./roles-admin";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 
+const isValidTab = (tab: string): tab is AvailableTabs => {
+  return [
+    "invite",
+    "api-tokens",
+    "manage-access",
+    "billing",
+    "additional-settings",
+    "roles-admin",
+  ].includes(tab);
+};
+
+type AvailableTabs =
+  | "invite"
+  | "api-tokens"
+  | "manage-access"
+  | "billing"
+  | "additional-settings"
+  | "roles-admin";
+
 type StateType = {
   projectName: string;
-  currentTab: string;
-  tabOptions: { value: string; label: string }[];
+  currentTab: AvailableTabs;
+  tabOptions: { value: AvailableTabs; label: string }[];
 };
 
 class ProjectSettings extends Component<PropsType, StateType> {
   state = {
     projectName: "",
-    currentTab: "manage-access",
-    tabOptions: [] as { value: string; label: string }[],
+    currentTab: "manage-access" as StateType["currentTab"],
+    tabOptions: [] as StateType["tabOptions"],
   };
 
   componentDidUpdate(prevProps: PropsType) {
     const selectedTab = getQueryParam(this.props, "selected_tab");
+
     if (prevProps.location.search !== this.props.location.search) {
-      if (selectedTab) {
+      if (selectedTab && isValidTab(selectedTab)) {
         this.setState({ currentTab: selectedTab });
       } else {
         this.setState({ currentTab: "manage-access" });
@@ -43,27 +63,15 @@ class ProjectSettings extends Component<PropsType, StateType> {
       !this.state.tabOptions.find((t) => t.value === "billing")
     ) {
       const tabOptions = this.state.tabOptions;
-      // tabOptions.splice(1, 0, { value: "billing", label: "Billing" });
       this.setState({ tabOptions });
       return;
     }
-
-    if (
-      !this.context?.hasBillingEnabled &&
-      this.state.tabOptions.find((t) => t.value === "billing")
-    ) {
-      const tabOptions = this.state.tabOptions;
-      const billingIndex = this.state.tabOptions.findIndex(
-        (t) => t.value === "billing"
-      );
-      // tabOptions.splice(billingIndex, 1);
-    }
   }
 
   componentDidMount() {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
-    const tabOptions = [];
+    const tabOptions = [] as StateType["tabOptions"];
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
     tabOptions.push({
       value: "billing",
@@ -71,13 +79,6 @@ class ProjectSettings extends Component<PropsType, StateType> {
     });
 
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
-      // if (this.context?.hasBillingEnabled) {
-      //   tabOptions.push({
-      //     value: "billing",
-      //     label: "Billing",
-      //   });
-      // }
-
       if (currentProject?.api_tokens_enabled) {
         tabOptions.push({
           value: "api-tokens",
@@ -91,10 +92,15 @@ class ProjectSettings extends Component<PropsType, StateType> {
       });
     }
 
+    tabOptions.push({
+      value: "roles-admin",
+      label: "Roles Admin",
+    });
+
     this.setState({ tabOptions });
 
     const selectedTab = getQueryParam(this.props, "selected_tab");
-    if (selectedTab) {
+    if (selectedTab && isValidTab(selectedTab)) {
       this.setState({ currentTab: selectedTab });
     }
   }
@@ -104,70 +110,68 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
-    // if (
-    //   this.state.currentTab === "billing" &&
-    //   this.context?.hasBillingEnabled
-    // ) {
-    //   return <BillingPage />;
-    // }
-
-    if (this.state.currentTab === "manage-access") {
-      return <InvitePage />;
-    } else if (this.state.currentTab === "api-tokens") {
-      return <APITokensSection />;
-    } else if (this.state.currentTab === "billing") {
-      return (
-        <Placeholder>
-          <Helper>
-            Visit the{" "}
-            <a
-              href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
-            >
-              billing portal
-            </a>{" "}
-            to view plans.
-          </Helper>
-        </Placeholder>
-      );
-    } else {
-      return (
-        <>
-          <Heading isAtTop={true}>Delete Project</Heading>
-          <Helper>
-            Permanently delete this project. This will destroy all clusters tied
-            to this project that have been provisioned by Porter. Note that this
-            will not delete the image registries provisioned by Porter. To
-            delete the registries, please do so manually in your cloud console.
-          </Helper>
-
-          <Helper>
-            Destruction of resources sometimes results in dangling resources. To
-            ensure that everything has been properly destroyed, please visit
-            your cloud provider's console. Instructions to properly delete all
-            resources can be found
-            <a
-              target="none"
-              href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+    switch (this.state.currentTab) {
+      case "roles-admin":
+        return <RolesAdmin />;
+      case "manage-access":
+        return <InvitePage />;
+      case "api-tokens":
+        return <APITokensSection />;
+      case "billing":
+        return (
+          <Placeholder>
+            <Helper>
+              Visit the{" "}
+              <a
+                href={`/api/projects/${this.context.currentProject?.id}/billing/redirect`}
+              >
+                billing portal
+              </a>{" "}
+              to view plans.
+            </Helper>
+          </Placeholder>
+        );
+      case "additional-settings":
+      default:
+        return (
+          <>
+            <Heading isAtTop={true}>Delete Project</Heading>
+            <Helper>
+              Permanently delete this project. This will destroy all clusters
+              tied to this project that have been provisioned by Porter. Note
+              that this will not delete the image registries provisioned by
+              Porter. To delete the registries, please do so manually in your
+              cloud console.
+            </Helper>
+
+            <Helper>
+              Destruction of resources sometimes results in dangling resources.
+              To ensure that everything has been properly destroyed, please
+              visit your cloud provider's console. Instructions to properly
+              delete all resources can be found
+              <a
+                target="none"
+                href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+              >
+                {" "}
+                here
+              </a>
+              .
+            </Helper>
+
+            <Warning highlight={true}>This action cannot be undone.</Warning>
+
+            <DeleteButton
+              onClick={() => {
+                this.context.setCurrentModal("UpdateProjectModal", {
+                  currentProject: this.context.currentProject,
+                });
+              }}
             >
-              {" "}
-              here
-            </a>
-            .
-          </Helper>
-
-          <Warning highlight={true}>This action cannot be undone.</Warning>
-
-          <DeleteButton
-            onClick={() => {
-              this.context.setCurrentModal("UpdateProjectModal", {
-                currentProject: this.context.currentProject,
-              });
-            }}
-          >
-            Delete Project
-          </DeleteButton>
-        </>
-      );
+              Delete Project
+            </DeleteButton>
+          </>
+        );
     }
   };
 
@@ -177,7 +181,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
         <TitleSection>Project Settings</TitleSection>
         <TabRegion
           currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          setCurrentTab={(x: AvailableTabs) => this.setState({ currentTab: x })}
           options={this.state.tabOptions}
         >
           {this.renderTabContents()}

+ 30 - 0
dashboard/src/main/home/project-settings/roles-admin/RolesAdmin.tsx

@@ -0,0 +1,30 @@
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+import CreateRole from "./pages/CreateRole";
+import EditRole from "./pages/EditRole";
+import ListRoles from "./pages/ListRoles";
+import { RolesAdminProvider } from "./Store";
+
+const AVAILABLE_PAGES = ["index", "create-role", "edit-role"] as const;
+
+type AVAILABLE_PAGES_TYPE = typeof AVAILABLE_PAGES[number];
+
+export type Navigate = (page: AVAILABLE_PAGES_TYPE) => void;
+
+export const RolesAdmin = () => {
+  const [page, setPage] = useState<AVAILABLE_PAGES_TYPE>("index");
+
+  const navigate: Navigate = (page) => {
+    setPage(page);
+  };
+
+  return (
+    <>
+      <RolesAdminProvider>
+        {page === "index" ? <ListRoles navigate={navigate} /> : null}
+        {page === "create-role" ? <CreateRole navigate={navigate} /> : null}
+        {page === "edit-role" && <EditRole navigate={navigate} />}
+      </RolesAdminProvider>
+    </>
+  );
+};

+ 204 - 0
dashboard/src/main/home/project-settings/roles-admin/Store.tsx

@@ -0,0 +1,204 @@
+import React, { createContext, useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { CreateRoleBody, Role, RoleList, UpdateRoleBody } from "./types";
+
+import { Context as GlobalContext } from "shared/Context";
+
+export const RolesAdminContext = createContext({
+  loading: false,
+  error: null,
+
+  setLoading: (loading: boolean) => {},
+  setError: (error: string) => {},
+  clearError: () => {},
+
+  currentRole: {} as Role,
+  setCurrentRole: (role: Role) => {},
+  clearCurrentRole: () => {},
+});
+
+export const RolesAdminProvider: React.FC = ({ children }) => {
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string>(null);
+  const [currentRole, setCurrentRole] = useState<Role>(null);
+  const [defaultHierarchyTree, setDefaultHierarchyTree] = useState(null);
+
+  const clearError = () => {
+    setError(null);
+  };
+
+  const clearCurrentRole = () => {
+    setCurrentRole(null);
+  };
+
+  const { currentProject } = useContext(GlobalContext);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    api
+      .getScopeHierarchy(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setDefaultHierarchyTree(res.data);
+      });
+  }, [currentProject?.id]);
+
+  return (
+    <RolesAdminContext.Provider
+      value={{
+        loading,
+        error,
+        setLoading,
+        setError,
+        clearError,
+        currentRole,
+        setCurrentRole,
+        clearCurrentRole,
+      }}
+    >
+      {children}
+    </RolesAdminContext.Provider>
+  );
+};
+
+export const useRoleList = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string>(null);
+  const [roles, setRoles] = useState<RoleList>([]);
+
+  const refetch = async () => {
+    setIsLoading(true);
+    setError(null);
+    try {
+      const res = await api.listRoles<RoleList>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+        }
+      );
+      setRoles(res.data);
+    } catch (err) {
+      setError(err.message);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    refetch();
+  }, []);
+
+  return {
+    refetch,
+    isLoading,
+    error,
+    roles,
+  };
+};
+
+export const useCreateRole = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const mutate = async (body: CreateRoleBody) => {
+    setIsLoading(true);
+    try {
+      const { data } = await api.createRole<Role>(
+        "<token>",
+        {
+          ...body,
+        },
+        {
+          project_id: currentProject.id,
+        }
+      );
+      return data;
+    } catch (error) {
+      setError(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return {
+    mutate,
+    loading: isLoading,
+    error,
+  };
+};
+
+export const useUpdateRole = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const mutate = async (body: UpdateRoleBody) => {
+    setIsLoading(true);
+    try {
+      const { data } = await api.updateRole<Role>(
+        "<token>",
+        {
+          ...body,
+        },
+        {
+          project_id: currentProject.id,
+          role_id: body.id,
+        }
+      );
+      return data;
+    } catch (error) {
+      setError(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return {
+    mutate,
+    loading: isLoading,
+    error,
+  };
+};
+
+export const useDeleteRole = () => {
+  const { currentProject } = useContext(GlobalContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(null);
+
+  const mutate = async (role_id: Role["id"]) => {
+    setIsLoading(true);
+    try {
+      await api.deleteRole(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          role_id,
+        }
+      );
+    } catch (error) {
+      setError(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return {
+    mutate,
+    loading: isLoading,
+    error,
+  };
+};

+ 216 - 0
dashboard/src/main/home/project-settings/roles-admin/components/PolicyDocumentRenderer.tsx

@@ -0,0 +1,216 @@
+import { capitalize, get, set } from "lodash";
+import React, { useContext, useEffect } from "react";
+import api from "shared/api";
+import {
+  POLICY_HIERARCHY_TREE,
+  populatePolicy,
+} from "shared/auth/authorization-helpers";
+import { PolicyDocType, ScopeType, Verbs } from "shared/auth/types";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+type VerbStore = {
+  data: PolicyDocType;
+  handleChangeVerbs: (path: string, values: string[]) => void;
+};
+
+// Store that will save the current state of the policy document,
+// only changes applied for the policy document are verb changes.
+const Store = React.createContext<VerbStore>({
+  data: null,
+  handleChangeVerbs: () => {},
+});
+
+const PolicyDocumentRenderer = ({
+  value,
+  onChange,
+  readOnly,
+}: {
+  value: PolicyDocType;
+  onChange: (data: PolicyDocType) => void;
+  readOnly?: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+  const [scopeHierarchy, setScopeHierarchy] = React.useState<any>(null);
+
+  useEffect(() => {
+    api
+      .getScopeHierarchy("<token>", {}, { project_id: currentProject.id })
+      .then((res) => {
+        setScopeHierarchy(res.data);
+        onChange(populatePolicy(value, res.data));
+      });
+  }, [currentProject?.id]);
+
+  const handleChangeVerbs = (dataPath: string, verbs: Verbs[]) => {
+    const newPolicyDoc = structuredClone(value) as PolicyDocType;
+
+    set(newPolicyDoc, dataPath, verbs);
+
+    onChange(newPolicyDoc);
+  };
+
+  if (!scopeHierarchy) {
+    return (
+      <>
+        <h1>Loading...</h1>
+      </>
+    );
+  }
+
+  return (
+    <Store.Provider
+      value={{ data: populatePolicy(value, scopeHierarchy), handleChangeVerbs }}
+    >
+      {RenderComponents(readOnly, value)}
+    </Store.Provider>
+  );
+};
+
+export default PolicyDocumentRenderer;
+
+const RenderComponents = (
+  readOnly: boolean,
+  policyDocument: PolicyDocType,
+  tree = POLICY_HIERARCHY_TREE,
+  dataPath = "",
+  anidationLevel = 0
+) => {
+  const scope = policyDocument.scope;
+
+  const currTree = tree[scope];
+  const treeKeys = Object.keys(currTree) as Array<ScopeType>;
+
+  let components: React.ReactElement[] = [];
+
+  const newDataPath = anidationLevel === 0 ? "" : dataPath + "." + scope;
+
+  const verbsPath = newDataPath === "" ? "verbs" : newDataPath + ".verbs";
+
+  const childrenPath =
+    newDataPath === "" ? "children" : newDataPath + ".children";
+
+  for (const child of treeKeys) {
+    let childPolicy = policyDocument.children[child];
+
+    if (!childPolicy) {
+      continue;
+    }
+
+    const children = RenderComponents(
+      readOnly,
+      childPolicy,
+      currTree,
+      childrenPath,
+      anidationLevel + 1
+    );
+    components = [...components, children];
+  }
+
+  const Component = (
+    <>
+      <Card anidationLevel={anidationLevel}>
+        <ScopePermissionsHandler
+          name={scope}
+          dataPath={verbsPath}
+          readOnly={readOnly}
+        />
+      </Card>
+      {components.map((c) => c)}
+    </>
+  );
+  return Component;
+};
+
+const Card = styled.div<{ anidationLevel: number }>`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-around;
+  background-color: #2b2e3699;
+  margin-left: ${({ anidationLevel }) => `${anidationLevel * 20}px`};
+  margin-bottom: 15px;
+`;
+
+const ScopePermissionsHandler = ({
+  name,
+  dataPath,
+  readOnly,
+}: {
+  name: string;
+  dataPath: string;
+  readOnly: boolean;
+}) => {
+  const { handleChangeVerbs, data } = React.useContext(Store);
+
+  const verbs = get(data, dataPath);
+
+  return (
+    <>
+      {name}
+      {readOnly ? null : (
+        <Select
+          values={verbs}
+          onChange={(newVerbs) => handleChangeVerbs(dataPath, newVerbs)}
+        />
+      )}
+    </>
+  );
+};
+
+type SelectProps = {
+  values: Verbs[];
+  onChange: (newVerbs: Verbs[]) => void;
+  disabled?: boolean;
+};
+
+const Select = ({ values, onChange }: SelectProps) => {
+  const options = ["create", "read", "update", "delete"] as const;
+  const [open, setOpen] = React.useState(false);
+
+  const handleChange = (opt: typeof options[number], add: boolean) => {
+    const verbs: Verbs[] = opt === "read" ? ["get", "list"] : [opt];
+
+    if (add) {
+      handleAdd(verbs);
+    } else {
+      handleDelete(verbs);
+    }
+  };
+
+  const handleAdd = (verbs: Verbs[]) => {
+    const newValues = [...verbs, ...values];
+    onChange(newValues);
+  };
+
+  const handleDelete = (verbs: Verbs[]) => {
+    const newValues = values.filter((val) => !verbs.includes(val));
+
+    onChange(newValues);
+  };
+
+  return (
+    <div>
+      <i onClick={() => setOpen(!open)}>{capitalize(values.join(", "))}</i>
+      <div>
+        {options.map((opt) => {
+          const isChecked =
+            opt === "read"
+              ? values.find((val) => val === "get")
+              : values.find((val) => val === opt);
+          return (
+            <div>
+              <input
+                type="checkbox"
+                name={opt}
+                checked={!!isChecked}
+                onChange={(e) => handleChange(opt, e.target.checked)}
+              />
+              <label htmlFor={opt}>{capitalize(opt)}</label>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+};

+ 1 - 0
dashboard/src/main/home/project-settings/roles-admin/index.ts

@@ -0,0 +1 @@
+export { RolesAdmin } from "./RolesAdmin";

+ 130 - 0
dashboard/src/main/home/project-settings/roles-admin/pages/CreateRole.tsx

@@ -0,0 +1,130 @@
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import SearchSelector from "components/SearchSelector";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import {
+  populatePolicy,
+  VIEWER_POLICY_MOCK,
+} from "shared/auth/authorization-helpers";
+import { PolicyDocType } from "shared/auth/types";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import PolicyDocumentRenderer from "../components/PolicyDocumentRenderer";
+import { Navigate } from "../RolesAdmin";
+import { RolesAdminContext, useCreateRole } from "../Store";
+
+type PartialUser = {
+  id: number;
+  email: string;
+};
+
+const CreateRole = ({ navigate }: { navigate: Navigate }) => {
+  const { currentProject } = useContext(Context);
+  const { mutate, loading, error } = useCreateRole();
+  const [name, setName] = useState("");
+  const [policyDocument, setPolicyDocument] = useState<PolicyDocType>(
+    VIEWER_POLICY_MOCK
+  );
+  const [availableUsers, setAvailableUsers] = useState<PartialUser[]>([]);
+  const [users, setUsers] = useState<PartialUser[]>([]);
+
+  useEffect(() => {
+    api
+      .getCollaborators<PartialUser[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      )
+      .then((res) => {
+        setAvailableUsers(res.data);
+      });
+  }, [currentProject]);
+
+  const filteredUsers = availableUsers.filter(
+    (user) => !users.find((u) => u.id === user.id)
+  );
+
+  const handleSave = async () => {
+    await mutate({
+      name,
+      policy: policyDocument,
+      users: users.map((user) => user.id),
+    });
+    navigate("index");
+  };
+
+  return (
+    <div style={{ paddingBottom: "300px" }}>
+      <h1>CreateRole</h1>
+
+      <button onClick={() => navigate("index")}>Back</button>
+
+      <InputWrapper>
+        <InputRow
+          type="string"
+          setValue={(val) => setName(val as string)}
+          value={name}
+          label="Name"
+          width="100%"
+        />
+      </InputWrapper>
+
+      <PolicyDocumentRenderer
+        value={policyDocument}
+        onChange={setPolicyDocument}
+      />
+
+      <SearchSelector
+        options={filteredUsers}
+        onSelect={(user) => {
+          setUsers([...users, user]);
+        }}
+        label="Users"
+        filterBy={(user) => user.email}
+        getOptionLabel={(user) => user.email}
+        placeholder="Search for users"
+        noOptionsText="Seems like you selected all users available!"
+      />
+      <UserList>
+        {users.map((user) => (
+          <User key={user.id}>
+            {user.email}
+            {/* add Delete button */}
+            <button
+              onClick={() => {
+                setUsers(users.filter((u) => u.id !== user.id));
+              }}
+            >
+              X
+            </button>
+          </User>
+        ))}
+      </UserList>
+      <SaveButton
+        text="Save"
+        onClick={handleSave}
+        makeFlush
+        clearPosition
+        status={loading ? "loading" : ""}
+      />
+    </div>
+  );
+};
+
+export default CreateRole;
+
+const InputWrapper = styled.div`
+  max-width: 300px;
+`;
+
+const UserList = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 10px;
+`;
+
+const User = styled.div`
+  margin-bottom: 10px;
+`;

+ 113 - 0
dashboard/src/main/home/project-settings/roles-admin/pages/EditRole.tsx

@@ -0,0 +1,113 @@
+import SearchSelector from "components/SearchSelector";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { populatePolicy } from "shared/auth/authorization-helpers";
+import { PolicyDocType } from "shared/auth/types";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import PolicyDocumentRenderer from "../components/PolicyDocumentRenderer";
+import { Navigate } from "../RolesAdmin";
+import { RolesAdminContext, useUpdateRole } from "../Store";
+
+type PartialUser = {
+  id: number;
+  email: string;
+};
+
+const EditRole = ({ navigate }: { navigate: Navigate }) => {
+  const { currentProject } = useContext(Context);
+  const { currentRole } = useContext(RolesAdminContext);
+  const [policy, setPolicy] = useState<PolicyDocType>(() => {
+    return currentRole.policy;
+  });
+
+  const [availableUsers, setAvailableUsers] = useState<PartialUser[]>([]);
+  const [users, setUsers] = useState<PartialUser[]>([]);
+  const { mutate, loading: saving, error: saveError } = useUpdateRole();
+
+  useEffect(() => {
+    api
+      .getCollaborators<PartialUser[]>(
+        "<token>",
+        {},
+        { project_id: currentProject.id }
+      )
+      .then((res) => {
+        setAvailableUsers(
+          res.data.filter((user) => !currentRole.users.includes(user.id))
+        );
+        setUsers(
+          res.data.filter((user) => currentRole.users.includes(user.id))
+        );
+      });
+  }, [currentProject]);
+
+  const filteredUsers = availableUsers.filter(
+    (user) => !users.find((u) => u.id === user.id)
+  );
+
+  const handleSave = () => {
+    mutate({
+      id: currentRole.id,
+      name: currentRole.name,
+      policy,
+      users: currentRole.users,
+    });
+
+    navigate("index");
+  };
+
+  return (
+    <div>
+      EditRole <button onClick={() => navigate("index")}>Back</button>
+      <h1>{currentRole.name}</h1>
+      <PolicyDocumentRenderer
+        value={policy}
+        onChange={(policy) => {
+          setPolicy(policy);
+        }}
+        readOnly={currentRole.id.includes(`${currentProject.id}-`)}
+      />
+      <SearchSelector
+        options={filteredUsers}
+        onSelect={(user) => {
+          setUsers([...users, user]);
+        }}
+        label="Users"
+        filterBy={(user) => user.email}
+        getOptionLabel={(user) => user.email}
+        placeholder="Search for users"
+        noOptionsText="Seems like you selected all users available!"
+      />
+      <UserList>
+        {users.map((user) => (
+          <User key={user.id}>
+            {user.email}
+            {/* add Delete button */}
+            <button
+              onClick={() => {
+                setUsers(users.filter((u) => u.id !== user.id));
+              }}
+            >
+              X
+            </button>
+          </User>
+        ))}
+      </UserList>
+      <button onClick={() => handleSave()}>Save</button>
+    </div>
+  );
+};
+
+export default EditRole;
+
+const UserList = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 10px;
+`;
+
+const User = styled.div`
+  margin-bottom: 10px;
+`;

+ 38 - 0
dashboard/src/main/home/project-settings/roles-admin/pages/ListRoles.tsx

@@ -0,0 +1,38 @@
+import React, { useContext } from "react";
+import { RolesAdminContext, useRoleList } from "../Store";
+import type { Navigate } from "../RolesAdmin";
+import { isEmpty } from "lodash";
+import { VIEWER_POLICY_MOCK } from "shared/auth/authorization-helpers";
+import { Role } from "../types";
+
+const ListRoles = ({ navigate }: { navigate: Navigate }) => {
+  const { setCurrentRole } = useContext(RolesAdminContext);
+
+  const { isLoading, roles, error, refetch } = useRoleList();
+
+  const handleEdit = (role: Role) => {
+    setCurrentRole(role);
+    navigate("edit-role");
+  };
+
+  return (
+    <div>
+      <button onClick={() => navigate("create-role")}>Create Role</button>
+      <h1>ListRoles</h1>
+      {isLoading ? (
+        <div>Loading...</div>
+      ) : (
+        <ul>
+          {roles.map((role) => (
+            <li key={role.id}>
+              {role.name}
+              <button onClick={() => handleEdit(role as Role)}>Edit</button>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+};
+
+export default ListRoles;

+ 14 - 0
dashboard/src/main/home/project-settings/roles-admin/types.ts

@@ -0,0 +1,14 @@
+import { PolicyDocType } from "shared/auth/types";
+
+export type Role = {
+  id: string; // role ID
+  name: string; // role name
+  users: number[]; // list of user IDs
+  policy: PolicyDocType;
+};
+
+export type RoleList = Role[];
+
+export type CreateRoleBody = Omit<Role, "id">;
+
+export type UpdateRoleBody = Role;

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

@@ -7,6 +7,10 @@ import {
   CreateStackBody,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
+import {
+  CreateRoleBody,
+  UpdateRoleBody,
+} from "main/home/project-settings/roles-admin/types";
 
 /**
  * Generic api call format
@@ -2163,6 +2167,79 @@ const removeStackEnvGroup = baseApi<
 
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
+// ROLES
+
+/**
+  POST /api/projects/{project_id}/project_roles
+  GET /api/projects/{project_id}/project_roles/{role_id}
+  GET /api/projects/{project_id}/project_roles
+  PATCH /api/projects/{project_id}/project_roles/{role_id}
+  DELETE /api/projects/{project_id}/project_roles/{role_id}
+  PATCH /api/projects/{project_id}/invites/{invite_id}
+  POST /api/projects/{project_id}/invites
+ */
+
+const listRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/project_roles`
+);
+
+const createRole = baseApi<
+  CreateRoleBody,
+  {
+    project_id: number;
+  }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/project_roles`);
+
+const getRole = baseApi<
+  {},
+  {
+    project_id: number;
+    role_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, role_id }) =>
+    `/api/projects/${project_id}/project_roles/${role_id}`
+);
+
+const updateRole = baseApi<
+  UpdateRoleBody,
+  {
+    project_id: number;
+    role_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, role_id }) =>
+    `/api/projects/${project_id}/project_roles/${role_id}`
+);
+
+const deleteRole = baseApi<
+  {},
+  {
+    project_id: number;
+    role_id: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, role_id }) =>
+    `/api/projects/${project_id}/project_roles/${role_id}`
+);
+
+const getScopeHierarchy = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>(
+  "GET",
+  ({ project_id }) =>
+    `/api/projects/${project_id}/project_roles/scope_hierarchy`
+);
+
+// /ROLES
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -2366,4 +2443,12 @@ export default {
 
   // STATUS
   getGithubStatus,
+
+  // ROLES
+  listRoles,
+  getRole,
+  createRole,
+  updateRole,
+  deleteRole,
+  getScopeHierarchy,
 };

+ 8 - 5
dashboard/src/shared/auth/authorization-helpers.ts

@@ -93,11 +93,14 @@ export const isAuthorized = (
 
 export const populatePolicy = (
   currPolicy: PolicyDocType,
-  tree: HIERARCHY_TREE,
-  currScope: ScopeType,
-  parentVerbs: Array<Verbs>
+  tree: HIERARCHY_TREE = POLICY_HIERARCHY_TREE,
+  currentScope?: ScopeType,
+  parentVerbs?: Array<Verbs>
 ) => {
-  const currTree = tree[currScope];
+  const scope = currentScope || currPolicy.scope;
+  const verbs = parentVerbs || currPolicy.verbs;
+
+  const currTree = tree[scope];
   const treeKeys = Object.keys(currTree) as Array<ScopeType>;
 
   currPolicy.children = currPolicy?.children || {};
@@ -108,7 +111,7 @@ export const populatePolicy = (
     if (!childPolicy) {
       childPolicy = {
         scope: child,
-        verbs: parentVerbs,
+        verbs: verbs,
         resources: [],
         children: {},
       };