Explorar el Código

get basic mvp of api token functionality working on frontend

Alexander Belanger hace 4 años
padre
commit
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/types"
 	"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/repository"
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -52,14 +52,14 @@ func (p *APITokenCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	uid, err := repository.GenerateRandomBytes(16)
+	uid, err := encryption.GenerateRandomBytes(16)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	secretKey, err := repository.GenerateRandomBytes(16)
+	secretKey, err := encryption.GenerateRandomBytes(16)
 
 	if err != nil {
 		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/config"
 	"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/repository"
 )
 
 type PolicyCreateHandler struct {
@@ -37,7 +37,7 @@ func (p *PolicyCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	uid, err := repository.GenerateRandomBytes(16)
+	uid, err := encryption.GenerateRandomBytes(16)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))

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

@@ -1066,5 +1066,34 @@ func getProjectRoutes(
 		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
 }

+ 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 CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
 import TokenList from "./api-tokens/TokenList";
+import SaveButton from "components/SaveButton";
 
 type Props = {};
 
@@ -38,6 +39,7 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
   const [isLoading, setIsLoading] = useState(true);
   const [apiTokens, setAPITokens] = useState<Array<APITokenMeta>>([]);
   const [shouldCreate, setShouldCreate] = useState(false);
+  const [expanded, setExpanded] = useState("");
 
   useEffect(() => {
     api
@@ -60,7 +62,12 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
   }
 
   if (shouldCreate) {
-    return <CreateAPITokenForm onCreate={() => setShouldCreate(false)} />;
+    return (
+      <CreateAPITokenForm
+        onCreate={() => setShouldCreate(false)}
+        back={() => setShouldCreate(false)}
+      />
+    );
   }
 
   const getTokenList = () => {
@@ -69,17 +76,36 @@ const APITokensSection: React.FunctionComponent<Props> = ({}) => {
     });
   };
 
+  const revokeToken = (id: string) => {
+    setAPITokens((toks) => toks.filter((tok) => tok.id !== id));
+  };
+
   return (
-    <>
+    <APITokensSectionWrapper>
       <Heading isAtTop={true}>API Tokens</Heading>
       <Helper>
         This displays all active API tokens, which are tokens that have not
         expired and have not been revoked.
       </Helper>
       <TokenListWrapper>
-        <TokenList tokens={apiTokens} />
+        <TokenList
+          tokens={apiTokens}
+          setExpanded={setExpanded}
+          expanded={expanded}
+          revokeToken={revokeToken}
+        />
       </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`
+  width: 100%;
+  max-height: 500px;
+  overflow-y: auto;
+`;
+
+const APITokensSectionWrapper = styled.div`
   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 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";
@@ -16,9 +17,12 @@ import RadioSelector from "components/RadioSelector";
 import SelectRow from "components/form-components/SelectRow";
 import SaveButton from "components/SaveButton";
 import { APIToken } from "../APITokensSection";
+import CustomPolicyForm from "./CustomPolicyForm";
+import { PolicyDocType, Verbs } from "shared/auth/types";
 
 type Props = {
   onCreate: () => void;
+  back: () => void;
 };
 
 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 [apiTokenName, setAPITokenName] = useState("");
   const dateOptions = getDateOptions();
@@ -81,20 +93,131 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
   const [policy, setPolicy] = useState("developer");
   const [createdToken, setCreatedToken] = useState<APIToken>(null);
   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 = () => {
+    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
-      .createAPIToken(
+      .createPolicy(
         "<token>",
         {
-          name: apiTokenName,
-          expires_at: getDateValue(expiration),
-          policy_uid: policy,
+          name: policyName,
+          policy: [policy],
         },
         { project_id: currentProject.id }
       )
       .then(({ data }) => {
-        setCreatedToken(data);
+        console.log("data response is", data);
+        cb && cb(data?.uid);
       })
       .catch((err) => {
         console.error(err);
@@ -104,7 +227,12 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
   if (createdToken != null) {
     return (
       <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>
           Please copy this token and store it in a secure location. This token
           will only be shown once:
@@ -121,14 +249,43 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
             </i>
           </CopyToClipboard>
         </TokenDisplayBlock>
-        <SaveButton text="Continue" onClick={onCreate} />
+        <SaveButton
+          text="Continue"
+          onClick={onCreate}
+          makeFlush={true}
+          clearPosition={true}
+        />
       </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 (
     <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
         value={apiTokenName}
         type="text"
@@ -161,9 +318,19 @@ const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
             label: "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>
   );
 };
@@ -232,14 +399,9 @@ const ButtonWrapper = styled.div`
 `;
 
 const CreateTokenWrapper = styled.div`
-  width: 40%;
-  min-width: 500px;
+  width: 60%;
+  min-width: 600px;
   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`
@@ -366,6 +528,7 @@ const TokenDisplayBlock = styled.div`
   justify-content: space-between;
   width: 100%;
   background-color: #1b1d26;
+  margin-bottom: 20px;
 `;
 
 const CopyTokenButton = styled.div`
@@ -391,3 +554,36 @@ const CodeBlock = styled.div`
   white-space: nowrap;
   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 { APITokenMeta } from "../APITokensSection";
+import { APIToken, APITokenMeta } from "../APITokensSection";
 
 type Props = {
   tokens: APITokenMeta[];
+  setExpanded: (id: string) => void;
+  expanded: string;
+  revokeToken: (id: string) => void;
 };
 
 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 (
     <>
       {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 (
-          <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;
 
-const PreviewRow = styled.div`
-  display: flex;
-  align-items: center;
-  padding: 12px 15px;
+const TokenWrapper = styled.div`
   color: #ffffff55;
   background: #ffffff01;
-  border: 1px solid #aaaabb;
-  justify-content: space-between;
+  border: 1px solid #aaaabbaa;
   font-size: 13px;
   border-radius: 5px;
   cursor: pointer;
-  margin: 16px 0;
+  margin: 8px 0;
   :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`
   display: flex;
   color: #ffffff;
@@ -58,24 +181,48 @@ const Right = styled.div`
   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;
-  align-items: center;
-  padding: 12px 15px;
-  color: #ffffff55;
-  background: #ffffff01;
-  border: 1px solid #aaaabb;
   justify-content: space-between;
+  align-items: center;
+`;
+
+const TimestampSection = styled.div`
+  margin-right: 8px;
+`;
+
+const RevokeAccessButton = styled.div`
+  display: inline-block;
   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;
-  margin: 16px 0;
+  width: 120px;
   :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 { FullActionConfigType, StorageType } from "./types";
@@ -422,9 +423,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("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<
@@ -440,9 +443,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("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<
@@ -458,9 +463,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("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<
@@ -1163,9 +1170,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("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<
@@ -1328,6 +1337,17 @@ const listAPITokens = baseApi<{}, { project_id: number }>(
   ({ 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<
   {
     name: string;
@@ -1337,6 +1357,14 @@ const createAPIToken = baseApi<
   { project_id: number }
 >("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 }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/roles`
@@ -1686,7 +1714,10 @@ export default {
   stopJob,
   updateInvite,
   listAPITokens,
+  getAPIToken,
+  revokeAPIToken,
   createAPIToken,
+  createPolicy,
   getAvailableRoles,
   getCollaborators,
   updateCollaborator,

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

@@ -4,7 +4,10 @@ export type ScopeType =
   | "settings"
   | "namespace"
   | "application"
+  | "release"
+  | "registry"
   | "env_group"
+  | "infra"
   | "job"
   | "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) {
 	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
 	}