Răsfoiți Sursa

get basic mvp of api token functionality working on frontend

Alexander Belanger 4 ani în urmă
părinte
comite
cffe16cf37

+ 3 - 3
api/server/handlers/api_token/create.go

@@ -11,8 +11,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
 )
 )
 
 
@@ -52,14 +52,14 @@ func (p *APITokenCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 		return
 	}
 	}
 
 
-	uid, err := repository.GenerateRandomBytes(16)
+	uid, err := encryption.GenerateRandomBytes(16)
 
 
 	if err != nil {
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
 
 
-	secretKey, err := repository.GenerateRandomBytes(16)
+	secretKey, err := encryption.GenerateRandomBytes(16)
 
 
 	if err != nil {
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 68 - 0
api/server/handlers/api_token/revoke.go

@@ -0,0 +1,68 @@
+package api_token
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type APITokenRevokeHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewAPITokenRevokeHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *APITokenRevokeHandler {
+	return &APITokenRevokeHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *APITokenRevokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the token id from the request
+	tokenID, reqErr := requestutils.GetURLParamString(r, types.URLParamTokenID)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	token, err := p.Repo().APIToken().ReadAPIToken(proj.ID, tokenID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("token with id %s not found in project", tokenID),
+				http.StatusNotFound,
+			))
+			return
+		}
+
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	token.Revoked = true
+
+	token, err = p.Repo().APIToken().UpdateAPIToken(token)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, token.ToAPITokenMetaType())
+}

+ 2 - 2
api/server/handlers/policy/create.go

@@ -9,8 +9,8 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
 )
 )
 
 
 type PolicyCreateHandler struct {
 type PolicyCreateHandler struct {
@@ -37,7 +37,7 @@ func (p *PolicyCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	uid, err := repository.GenerateRandomBytes(16)
+	uid, err := encryption.GenerateRandomBytes(16)
 
 
 	if err != nil {
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 29 - 0
api/server/router/project.go

@@ -1066,5 +1066,34 @@ func getProjectRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	//  POST /api/projects/{project_id}/api_token/{api_token_id}/revoke -> api_token.NewAPITokenRevokeHandler
+	apiTokenRevokeEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/api_token/{%s}/revoke", relPath, types.URLParamTokenID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	apiTokenRevokeHandler := api_token.NewAPITokenRevokeHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: apiTokenRevokeEndpoint,
+		Handler:  apiTokenRevokeHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 52 - 5
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -15,6 +15,7 @@ import Table from "components/Table";
 import RadioSelector from "components/RadioSelector";
 import RadioSelector from "components/RadioSelector";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import TokenList from "./api-tokens/TokenList";
 import TokenList from "./api-tokens/TokenList";
+import SaveButton from "components/SaveButton";
 
 
 type Props = {};
 type Props = {};
 
 
@@ -38,6 +39,7 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [apiTokens, setAPITokens] = useState<Array<APITokenMeta>>([]);
   const [apiTokens, setAPITokens] = useState<Array<APITokenMeta>>([]);
   const [shouldCreate, setShouldCreate] = useState(false);
   const [shouldCreate, setShouldCreate] = useState(false);
+  const [expanded, setExpanded] = useState("");
 
 
   useEffect(() => {
   useEffect(() => {
     api
     api
@@ -60,7 +62,12 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
   }
   }
 
 
   if (shouldCreate) {
   if (shouldCreate) {
-    return <CreateAPITokenForm onCreate={() => setShouldCreate(false)} />;
+    return (
+      <CreateAPITokenForm
+        onCreate={() => setShouldCreate(false)}
+        back={() => setShouldCreate(false)}
+      />
+    );
   }
   }
 
 
   const getTokenList = () => {
   const getTokenList = () => {
@@ -69,17 +76,36 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
     });
     });
   };
   };
 
 
+  const revokeToken = (id: string) => {
+    setAPITokens((toks) => toks.filter((tok) => tok.id !== id));
+  };
+
   return (
   return (
-    <>
+    <APITokensSectionWrapper>
       <Heading isAtTop={true}>API Tokens</Heading>
       <Heading isAtTop={true}>API Tokens</Heading>
       <Helper>
       <Helper>
         This displays all active API tokens, which are tokens that have not
         This displays all active API tokens, which are tokens that have not
         expired and have not been revoked.
         expired and have not been revoked.
       </Helper>
       </Helper>
       <TokenListWrapper>
       <TokenListWrapper>
-        <TokenList tokens={apiTokens} />
+        <TokenList
+          tokens={apiTokens}
+          setExpanded={setExpanded}
+          expanded={expanded}
+          revokeToken={revokeToken}
+        />
       </TokenListWrapper>
       </TokenListWrapper>
-    </>
+      <SaveButtonContainer>
+        <SaveButton
+          makeFlush={true}
+          clearPosition={true}
+          onClick={() => setShouldCreate(true)}
+        >
+          <i className="material-icons">add</i>
+          Create API Token
+        </SaveButton>
+      </SaveButtonContainer>
+    </APITokensSectionWrapper>
   );
   );
 };
 };
 
 
@@ -270,6 +296,27 @@ const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
 `;
 `;
 
 
 const TokenListWrapper = styled.div`
 const TokenListWrapper = styled.div`
+  width: 100%;
+  max-height: 500px;
+  overflow-y: auto;
+`;
+
+const APITokensSectionWrapper = styled.div`
   width: 60%;
   width: 60%;
-  min-width: 400px;
+  min-width: 600px;
+`;
+
+const ControlRow = styled.div`
+  width: 100%;
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const SaveButtonContainer = styled.div`
+  position: relative;
+  margin-top: 20px;
 `;
 `;

+ 213 - 17
dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 import { InviteType } from "shared/types";
 import { InviteType } from "shared/types";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
+import backArrow from "assets/back_arrow.png";
 
 
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import InputRow from "components/form-components/InputRow";
 import InputRow from "components/form-components/InputRow";
@@ -16,9 +17,12 @@ import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
 import { APIToken } from "../APITokensSection";
 import { APIToken } from "../APITokensSection";
+import CustomPolicyForm from "./CustomPolicyForm";
+import { PolicyDocType, Verbs } from "shared/auth/types";
 
 
 type Props = {
 type Props = {
   onCreate: () => void;
   onCreate: () => void;
+  back: () => void;
 };
 };
 
 
 const getDateValue = (option: string): string => {
 const getDateValue = (option: string): string => {
@@ -73,7 +77,15 @@ export const getDateOptions = (): { value: string; label: string }[] => {
   ];
   ];
 };
 };
 
 
-const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
+export type ScopeOption = {
+  value: string;
+  label: string;
+};
+
+const CreateAPITokenForm: React.FunctionComponent<Props> = ({
+  onCreate,
+  back,
+}) => {
   const { currentProject } = useContext(Context);
   const { currentProject } = useContext(Context);
   const [apiTokenName, setAPITokenName] = useState("");
   const [apiTokenName, setAPITokenName] = useState("");
   const dateOptions = getDateOptions();
   const dateOptions = getDateOptions();
@@ -81,20 +93,131 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
   const [policy, setPolicy] = useState("developer");
   const [policy, setPolicy] = useState("developer");
   const [createdToken, setCreatedToken] = useState<APIToken>(null);
   const [createdToken, setCreatedToken] = useState<APIToken>(null);
   const [copied, setCopied] = useState(false);
   const [copied, setCopied] = useState(false);
+  const [shouldCreatePolicy, setShouldCreatePolicy] = useState(false);
+  const [selectedClusterFields, setSelectedClusterFields] = useState<
+    ScopeOption[]
+  >([]);
+  const [selectedRegistryFields, setSelectedRegistryFields] = useState<
+    ScopeOption[]
+  >([]);
+  const [selectedInfraFields, setSelectedInfraFields] = useState<ScopeOption[]>(
+    []
+  );
+  const [selectedSettingsFields, setSelectedSettingsFields] = useState<
+    ScopeOption[]
+  >([]);
+  const [policyName, setPolicyName] = useState("");
+  const [policyID, setPolicyID] = useState("");
 
 
   const createToken = () => {
   const createToken = () => {
+    createPolicy((policyUID) => {
+      console.log("policy uid is", policyUID);
+
+      api
+        .createAPIToken(
+          "<token>",
+          {
+            name: apiTokenName,
+            expires_at: getDateValue(expiration),
+            policy_uid: policyUID || policy,
+          },
+          { project_id: currentProject.id }
+        )
+        .then(({ data }) => {
+          setCreatedToken(data);
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+    });
+  };
+
+  const getVerbsForScope = (
+    scopeVal: string,
+    allSelected: string[]
+  ): Verbs[] => {
+    if (scopeVal.includes("read")) {
+      return allSelected.includes(scopeVal) ? ["get", "list"] : [];
+    } else if (scopeVal.includes("write")) {
+      return allSelected.includes(scopeVal)
+        ? ["create", "update", "delete"]
+        : [];
+    } else {
+      return [];
+    }
+  };
+
+  const createPolicy = (cb?: (id: string) => void) => {
+    let allSelectedFields = selectedClusterFields.concat(
+      ...selectedRegistryFields,
+      ...selectedInfraFields,
+      ...selectedSettingsFields
+    );
+
+    let allSelectedValues = allSelectedFields.map((field) => field.value);
+
+    // construct the policy
+    let policy: PolicyDocType = {
+      scope: "project",
+      verbs: [],
+      children: {
+        cluster: {
+          scope: "cluster",
+          verbs: [],
+          children: {
+            namespace: {
+              scope: "namespace",
+              verbs: getVerbsForScope(
+                "namespace-read",
+                allSelectedValues
+              ).concat(getVerbsForScope("namespace-write", allSelectedValues)),
+              children: {
+                release: {
+                  scope: "release",
+                  verbs: getVerbsForScope(
+                    "release-read",
+                    allSelectedValues
+                  ).concat(
+                    getVerbsForScope("release-write", allSelectedValues)
+                  ),
+                },
+              },
+            },
+          },
+        },
+        registry: {
+          scope: "registry",
+          verbs: getVerbsForScope("registry-read", allSelectedValues).concat(
+            getVerbsForScope("registry-write", allSelectedValues)
+          ),
+        },
+        infra: {
+          scope: "infra",
+          verbs: getVerbsForScope("infra-read", allSelectedValues).concat(
+            getVerbsForScope("infra-write", allSelectedValues)
+          ),
+        },
+        settings: {
+          scope: "settings",
+          verbs: getVerbsForScope("settings-read", allSelectedValues).concat(
+            getVerbsForScope("settings-write", allSelectedValues)
+          ),
+        },
+      },
+    };
+
     api
     api
-      .createAPIToken(
+      .createPolicy(
         "<token>",
         "<token>",
         {
         {
-          name: apiTokenName,
-          expires_at: getDateValue(expiration),
-          policy_uid: policy,
+          name: policyName,
+          policy: [policy],
         },
         },
         { project_id: currentProject.id }
         { project_id: currentProject.id }
       )
       )
       .then(({ data }) => {
       .then(({ data }) => {
-        setCreatedToken(data);
+        console.log("data response is", data);
+        cb && cb(data?.uid);
       })
       })
       .catch((err) => {
       .catch((err) => {
         console.error(err);
         console.error(err);
@@ -104,7 +227,12 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
   if (createdToken != null) {
   if (createdToken != null) {
     return (
     return (
       <CreateTokenWrapper>
       <CreateTokenWrapper>
-        <Heading isAtTop={true}>API token created successfully!</Heading>
+        <ControlRow>
+          <Heading isAtTop={true}>API token created successfully!</Heading>
+          <BackButton>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
+        </ControlRow>
         <Helper>
         <Helper>
           Please copy this token and store it in a secure location. This token
           Please copy this token and store it in a secure location. This token
           will only be shown once:
           will only be shown once:
@@ -121,14 +249,43 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
             </i>
             </i>
           </CopyToClipboard>
           </CopyToClipboard>
         </TokenDisplayBlock>
         </TokenDisplayBlock>
-        <SaveButton text="Continue" onClick={onCreate} />
+        <SaveButton
+          text="Continue"
+          onClick={onCreate}
+          makeFlush={true}
+          clearPosition={true}
+        />
       </CreateTokenWrapper>
       </CreateTokenWrapper>
     );
     );
   }
   }
 
 
+  const renderPolicyContents = () => {
+    if (policy === "custom") {
+      return (
+        <CustomPolicyForm
+          selectedClusterFields={selectedClusterFields}
+          setSelectedClusterFields={setSelectedClusterFields}
+          selectedRegistryFields={selectedRegistryFields}
+          setSelectedRegistryFields={setSelectedRegistryFields}
+          selectedInfraFields={selectedInfraFields}
+          setSelectedInfraFields={setSelectedInfraFields}
+          selectedSettingsFields={selectedSettingsFields}
+          setSelectedSettingsFields={setSelectedSettingsFields}
+          policyName={policyName}
+          setPolicyName={setPolicyName}
+        />
+      );
+    }
+  };
+
   return (
   return (
     <CreateTokenWrapper>
     <CreateTokenWrapper>
-      <Heading isAtTop={true}>Create API Token</Heading>
+      <ControlRow>
+        <Heading isAtTop={true}>Create API Token</Heading>
+        <BackButton onClick={back}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+      </ControlRow>
       <InputRow
       <InputRow
         value={apiTokenName}
         value={apiTokenName}
         type="text"
         type="text"
@@ -161,9 +318,19 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
             label: "Viewer",
             label: "Viewer",
             value: "viewer",
             value: "viewer",
           },
           },
+          {
+            label: "Custom Role",
+            value: "custom",
+          },
         ]}
         ]}
       />
       />
-      <SaveButton text="Create Token" onClick={createToken} />
+      {renderPolicyContents()}
+      <SaveButton
+        text="Create Token"
+        onClick={createToken}
+        makeFlush={true}
+        clearPosition={true}
+      />
     </CreateTokenWrapper>
     </CreateTokenWrapper>
   );
   );
 };
 };
@@ -232,14 +399,9 @@ const ButtonWrapper = styled.div`
 `;
 `;
 
 
 const CreateTokenWrapper = styled.div`
 const CreateTokenWrapper = styled.div`
-  width: 40%;
-  min-width: 500px;
+  width: 60%;
+  min-width: 600px;
   position: relative;
   position: relative;
-  height: 400px;
-  background: #26282f;
-  box-shadow: 0 4px 15px 0px #00000044;
-  padding: 20px 24px 10px 24px;
-  margin: 0 auto;
 `;
 `;
 
 
 const CopyButton = styled.div`
 const CopyButton = styled.div`
@@ -366,6 +528,7 @@ const TokenDisplayBlock = styled.div`
   justify-content: space-between;
   justify-content: space-between;
   width: 100%;
   width: 100%;
   background-color: #1b1d26;
   background-color: #1b1d26;
+  margin-bottom: 20px;
 `;
 `;
 
 
 const CopyTokenButton = styled.div`
 const CopyTokenButton = styled.div`
@@ -391,3 +554,36 @@ const CodeBlock = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   border-right: 10px solid #1b1d26;
   border-right: 10px solid #1b1d26;
 `;
 `;
+
+const ControlRow = styled.div`
+  width: 100%;
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;

+ 135 - 0
dashboard/src/main/home/project-settings/api-tokens/CustomPolicyForm.tsx

@@ -0,0 +1,135 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import { InviteType } from "shared/types";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import backArrow from "assets/back_arrow.png";
+
+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 { Column } from "react-table";
+import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import { APIToken } from "../APITokensSection";
+import CheckboxList from "components/form-components/CheckboxList";
+import { PolicyDocType } from "shared/auth/types";
+import { ScopeOption } from "./CreateAPITokenForm";
+
+type Props = {
+  selectedClusterFields: ScopeOption[];
+  setSelectedClusterFields: (scope: ScopeOption[]) => void;
+  selectedRegistryFields: ScopeOption[];
+  setSelectedRegistryFields: (scope: ScopeOption[]) => void;
+  selectedInfraFields: ScopeOption[];
+  setSelectedInfraFields: (scope: ScopeOption[]) => void;
+  selectedSettingsFields: ScopeOption[];
+  setSelectedSettingsFields: (scope: ScopeOption[]) => void;
+  policyName: string;
+  setPolicyName: (name: string) => void;
+};
+
+const clusterSettingsOptions = [
+  { value: "namespace-read", label: "Read access to namespaces" },
+  { value: "namespace-write", label: "Write access to namespaces" },
+  {
+    value: "release-read",
+    label: "Read access to releases (applications, jobs, other helm charts)",
+  },
+  {
+    value: "release-write",
+    label: "Write access to releases (applications, jobs, other helm charts)",
+  },
+];
+
+const registrySettingsOptions = [
+  { value: "registry-read", label: "Read access to registries" },
+  { value: "registry-write", label: "Write access to registries" },
+];
+
+const infraSettingsOptions = [
+  {
+    value: "infra-read",
+    label:
+      "Read access to infrastructure (provisioned clusters, registries, and databases)",
+  },
+  {
+    value: "infra-write",
+    label:
+      "Write access to infrastructure (provisioned clusters, registries, and databases)",
+  },
+];
+
+const projectSettingsOptions = [
+  {
+    value: "settings-read",
+    label: "Read access to settings (project invites, API tokens, billing)",
+  },
+  {
+    value: "settings-write",
+    label: "Write access to settings (project invites, API tokens, billing)",
+  },
+];
+
+const CustomPolicyForm: React.FunctionComponent<Props> = ({
+  selectedClusterFields,
+  setSelectedClusterFields,
+  selectedRegistryFields,
+  setSelectedRegistryFields,
+  selectedInfraFields,
+  setSelectedInfraFields,
+  selectedSettingsFields,
+  setSelectedSettingsFields,
+  policyName,
+  setPolicyName,
+}) => {
+  return (
+    <CustomPolicyFormWrapper>
+      <Heading>Custom Role Settings</Heading>
+      <InputRow
+        value={policyName}
+        type="text"
+        setValue={(newName: string) => setPolicyName(newName)}
+        label="Role Name"
+        width="100%"
+        placeholder="ex: custom-developer-role"
+        isRequired={true}
+      />
+      <Helper color="white">Cluster Access:</Helper>
+      <CheckboxList
+        options={clusterSettingsOptions}
+        selected={selectedClusterFields}
+        setSelected={setSelectedClusterFields}
+      />
+      <Helper color="white">Registry Access:</Helper>
+      <CheckboxList
+        options={registrySettingsOptions}
+        selected={selectedRegistryFields}
+        setSelected={setSelectedRegistryFields}
+      />
+      <Helper color="white">Infra Access:</Helper>
+      <CheckboxList
+        options={infraSettingsOptions}
+        selected={selectedInfraFields}
+        setSelected={setSelectedInfraFields}
+      />
+      <Helper color="white">Settings Access:</Helper>
+      <CheckboxList
+        options={projectSettingsOptions}
+        selected={selectedSettingsFields}
+        setSelected={setSelectedSettingsFields}
+      />
+    </CustomPolicyFormWrapper>
+  );
+};
+
+export default CustomPolicyForm;
+
+const CustomPolicyFormWrapper = styled.div`
+  margin-bottom: 20px;
+`;

+ 176 - 29
dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx

@@ -1,23 +1,143 @@
-import React from "react";
+import Description from "components/Description";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+
+import api from "shared/api";
+import { readableDate } from "shared/string_utils";
 import styled from "styled-components";
 import styled from "styled-components";
-import { APITokenMeta } from "../APITokensSection";
+import { APIToken, APITokenMeta } from "../APITokensSection";
 
 
 type Props = {
 type Props = {
   tokens: APITokenMeta[];
   tokens: APITokenMeta[];
+  setExpanded: (id: string) => void;
+  expanded: string;
+  revokeToken: (id: string) => void;
 };
 };
 
 
 const TokenList: React.FunctionComponent<Props> = (props) => {
 const TokenList: React.FunctionComponent<Props> = (props) => {
+  const [expandedTok, setExpandedTok] = useState<APIToken>(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const { currentProject } = useContext(Context);
+
+  useEffect(() => {
+    if (props.expanded != "") {
+      setIsLoading(true);
+
+      api
+        .getAPIToken(
+          "<token>",
+          {},
+          { project_id: currentProject.id, token: props.expanded }
+        )
+        .then(({ data }) => {
+          setExpandedTok(data);
+          setIsLoading(false);
+        })
+        .catch((err) => {
+          console.error(err);
+        });
+    }
+  }, [currentProject, props.expanded]);
+
+  const revokeAPIToken = (id: string) => {
+    setIsLoading(true);
+
+    api
+      .revokeAPIToken(
+        "<token>",
+        {},
+        { project_id: currentProject.id, token: id }
+      )
+      .then(() => {
+        props.revokeToken(id);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  const renderExpandedContents = () => {
+    if (isLoading || !expandedTok) {
+      return (
+        <Placeholder>
+          <Loading />
+        </Placeholder>
+      );
+    }
+
+    return (
+      <StyledExpandedToken>
+        <Description margin="0">
+          Created at {readableDate(expandedTok.created_at)}. Using token policy:{" "}
+          {expandedTok.policy_name}.
+        </Description>
+        <RevokeAccessButtonWrapper>
+          <RevokeAccessButton onClick={() => revokeAPIToken(expandedTok.id)}>
+            Revoke Token
+          </RevokeAccessButton>
+        </RevokeAccessButtonWrapper>
+      </StyledExpandedToken>
+    );
+  };
+
   return (
   return (
     <>
     <>
       {props.tokens.map((token) => {
       {props.tokens.map((token) => {
+        if (props.expanded == token.id) {
+          return (
+            <TokenWrapper>
+              <TokenHeader
+                key={token.id}
+                onClick={() => {
+                  setIsLoading(false);
+                  props.setExpanded("");
+                }}
+              >
+                <Flex>
+                  <i className="material-icons">token</i>
+                  {token.name}
+                </Flex>
+                <Right>
+                  <RightHeaderSection>
+                    <TimestampSection>
+                      Expires at {readableDate(token.expires_at)}
+                    </TimestampSection>
+                    <i className="material-icons">expand_less</i>
+                  </RightHeaderSection>
+                </Right>
+              </TokenHeader>
+              {renderExpandedContents()}
+            </TokenWrapper>
+          );
+        }
+
         return (
         return (
-          <PreviewRow key={token.id}>
-            <Flex>
-              <i className="material-icons">token</i>
-              {token.name}
-            </Flex>
-            <Right>Expires at {token.expires_at}</Right>
-          </PreviewRow>
+          <TokenWrapper>
+            <TokenHeader
+              key={token.id}
+              onClick={() => {
+                setIsLoading(true);
+                props.setExpanded(token.id);
+              }}
+            >
+              <Flex>
+                <i className="material-icons">token</i>
+                {token.name}
+              </Flex>
+              <Right>
+                <RightHeaderSection>
+                  <TimestampSection>
+                    Expires at {readableDate(token.expires_at)}
+                  </TimestampSection>
+                  <i className="material-icons">expand_more</i>
+                </RightHeaderSection>
+              </Right>
+            </TokenHeader>
+          </TokenWrapper>
         );
         );
       })}
       })}
     </>
     </>
@@ -26,23 +146,26 @@ const TokenList: React.FunctionComponent<Props> = (props) => {
 
 
 export default TokenList;
 export default TokenList;
 
 
-const PreviewRow = styled.div`
-  display: flex;
-  align-items: center;
-  padding: 12px 15px;
+const TokenWrapper = styled.div`
   color: #ffffff55;
   color: #ffffff55;
   background: #ffffff01;
   background: #ffffff01;
-  border: 1px solid #aaaabb;
-  justify-content: space-between;
+  border: 1px solid #aaaabbaa;
   font-size: 13px;
   font-size: 13px;
   border-radius: 5px;
   border-radius: 5px;
   cursor: pointer;
   cursor: pointer;
-  margin: 16px 0;
+  margin: 8px 0;
   :hover {
   :hover {
-    background: #ffffff10;
+    border: 1px solid #aaaabb;
   }
   }
 `;
 `;
 
 
+const TokenHeader = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 12px 15px;
+  justify-content: space-between;
+`;
+
 const Flex = styled.div`
 const Flex = styled.div`
   display: flex;
   display: flex;
   color: #ffffff;
   color: #ffffff;
@@ -58,24 +181,48 @@ const Right = styled.div`
   text-align: right;
   text-align: right;
 `;
 `;
 
 
-const CreateNewRow = styled(PreviewRow)`
-  background: none;
+const StyledExpandedToken = styled.div`
+  padding: 12px 20px;
+  max-height: 300px;
+  overflow-y: auto;
 `;
 `;
 
 
-const CreateNewRowLink = styled.a`
-  background: none;
+const ExpandIconContainer = styled.div`
+  width: 30px;
+  margin-left: 10px;
+  padding-top: 2px;
+`;
+
+const RightHeaderSection = styled.div`
   display: flex;
   display: flex;
-  align-items: center;
-  padding: 12px 15px;
-  color: #ffffff55;
-  background: #ffffff01;
-  border: 1px solid #aaaabb;
   justify-content: space-between;
   justify-content: space-between;
+  align-items: center;
+`;
+
+const TimestampSection = styled.div`
+  margin-right: 8px;
+`;
+
+const RevokeAccessButton = styled.div`
+  display: inline-block;
   font-size: 13px;
   font-size: 13px;
-  border-radius: 5px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  padding: 6px 10px;
+  text-align: center;
+  border: 1px solid #ffffff55;
+  border-radius: 4px;
+  background: #ffffff11;
+  color: #ffffffdd;
   cursor: pointer;
   cursor: pointer;
-  margin: 16px 0;
+  width: 120px;
   :hover {
   :hover {
-    background: #ffffff10;
+    background: #ffffff22;
   }
   }
 `;
 `;
+
+const RevokeAccessButtonWrapper = styled.div`
+  width: 100%;
+  text-align: right;
+  margin-top: 12px;
+`;

+ 43 - 12
dashboard/src/shared/api.tsx

@@ -1,3 +1,4 @@
+import { PolicyDocType } from "./auth/types";
 import { baseApi } from "./baseApi";
 import { baseApi } from "./baseApi";
 
 
 import { FullActionConfigType, StorageType } from "./types";
 import { FullActionConfigType, StorageType } from "./types";
@@ -422,9 +423,11 @@ const detectBuildpack = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 });
 
 
 const getBranchContents = baseApi<
 const getBranchContents = baseApi<
@@ -440,9 +443,11 @@ const getBranchContents = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 });
 
 
 const getProcfileContents = baseApi<
 const getProcfileContents = baseApi<
@@ -458,9 +463,11 @@ const getProcfileContents = baseApi<
     branch: string;
     branch: string;
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 });
 
 
 const getBranches = baseApi<
 const getBranches = baseApi<
@@ -1163,9 +1170,11 @@ const getEnvGroup = baseApi<
     version?: number;
     version?: number;
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 });
 
 
 const getConfigMap = baseApi<
 const getConfigMap = baseApi<
@@ -1328,6 +1337,17 @@ const listAPITokens = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/api_token`
   ({ project_id }) => `/api/projects/${project_id}/api_token`
 );
 );
 
 
+const getAPIToken = baseApi<{}, { project_id: number; token: string }>(
+  "GET",
+  ({ project_id, token }) => `/api/projects/${project_id}/api_token/${token}`
+);
+
+const revokeAPIToken = baseApi<{}, { project_id: number; token: string }>(
+  "POST",
+  ({ project_id, token }) =>
+    `/api/projects/${project_id}/api_token/${token}/revoke`
+);
+
 const createAPIToken = baseApi<
 const createAPIToken = baseApi<
   {
   {
     name: string;
     name: string;
@@ -1337,6 +1357,14 @@ const createAPIToken = baseApi<
   { project_id: number }
   { project_id: number }
 >("POST", ({ project_id }) => `/api/projects/${project_id}/api_token`);
 >("POST", ({ project_id }) => `/api/projects/${project_id}/api_token`);
 
 
+const createPolicy = baseApi<
+  {
+    name: string;
+    policy: PolicyDocType[];
+  },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/policy`);
+
 const getAvailableRoles = baseApi<{}, { project_id: number }>(
 const getAvailableRoles = baseApi<{}, { project_id: number }>(
   "GET",
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/roles`
   ({ project_id }) => `/api/projects/${project_id}/roles`
@@ -1686,7 +1714,10 @@ export default {
   stopJob,
   stopJob,
   updateInvite,
   updateInvite,
   listAPITokens,
   listAPITokens,
+  getAPIToken,
+  revokeAPIToken,
   createAPIToken,
   createAPIToken,
+  createPolicy,
   getAvailableRoles,
   getAvailableRoles,
   getCollaborators,
   getCollaborators,
   updateCollaborator,
   updateCollaborator,

+ 3 - 0
dashboard/src/shared/auth/types.ts

@@ -4,7 +4,10 @@ export type ScopeType =
   | "settings"
   | "settings"
   | "namespace"
   | "namespace"
   | "application"
   | "application"
+  | "release"
+  | "registry"
   | "env_group"
   | "env_group"
+  | "infra"
   | "job"
   | "job"
   | "integrations";
   | "integrations";
 
 

+ 1 - 1
internal/repository/gorm/api_token.go

@@ -27,7 +27,7 @@ func (repo *APITokenRepository) CreateAPIToken(a *models.APIToken) (*models.APIT
 func (repo *APITokenRepository) ListAPITokensByProjectID(projectID uint) ([]*models.APIToken, error) {
 func (repo *APITokenRepository) ListAPITokensByProjectID(projectID uint) ([]*models.APIToken, error) {
 	tokens := []*models.APIToken{}
 	tokens := []*models.APIToken{}
 
 
-	if err := repo.db.Where("project_id = ?", projectID).Find(&tokens).Error; err != nil {
+	if err := repo.db.Where("project_id = ? AND revoked IS NOT ?", projectID, true).Find(&tokens).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}