فهرست منبع

add initial frontend components for api tokens

Alexander Belanger 4 سال پیش
والد
کامیت
733c29fc95

+ 7 - 1
api/server/authn/handler.go

@@ -132,7 +132,13 @@ func (authn *AuthN) verifyTokenWithNext(w http.ResponseWriter, r *http.Request,
 			return
 		}
 
-		// compare the secret against the hashed version
+		// first ensure that the token hasn't been revoked, and the token has not expired
+		if apiToken.Revoked || apiToken.IsExpired() {
+			authn.sendForbiddenError(fmt.Errorf("token with id %s not valid", tok.TokenID), w, r)
+			return
+		}
+
+		// next, compare the secret against the hashed version
 		if err := bcrypt.CompareHashAndPassword([]byte(apiToken.SecretKey), []byte(tok.Secret)); err != nil {
 			authn.sendForbiddenError(fmt.Errorf("incorrect secret key for token %s", tok.TokenID), w, r)
 			return

+ 57 - 10
api/server/authz/policy/loader.go

@@ -1,7 +1,9 @@
 package policy
 
 import (
+	"errors"
 	"fmt"
+	"net/http"
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
@@ -33,17 +35,11 @@ func (b *RepoPolicyDocumentLoader) LoadPolicyDocuments(
 	opts *PolicyLoaderOpts,
 ) ([]*types.PolicyDocument, apierrors.RequestError) {
 	if opts.Token != nil {
-		// load the policy from the repo
-		policy, err := b.policyRepo.ReadPolicy(opts.Token.ProjectID, opts.Token.PolicyUID)
+		// load the policy
+		apiPolicy, reqErr := GetAPIPolicyFromUID(b.policyRepo, opts.Token.ProjectID, opts.Token.PolicyUID)
 
-		if err != nil {
-			return nil, apierrors.NewErrInternal(err)
-		}
-
-		apiPolicy, err := policy.ToAPIPolicyType()
-
-		if err != nil {
-			return nil, apierrors.NewErrInternal(err)
+		if reqErr != nil {
+			return nil, reqErr
 		}
 
 		return apiPolicy.Policy, nil
@@ -113,3 +109,54 @@ var ViewerPolicy = []*types.PolicyDocument{
 		},
 	},
 }
+
+func GetAPIPolicyFromUID(policyRepo repository.PolicyRepository, projectID uint, uid string) (*types.APIPolicy, apierrors.RequestError) {
+	switch uid {
+	case "admin":
+		return &types.APIPolicy{
+			APIPolicyMeta: &types.APIPolicyMeta{
+				Name: "admin",
+				UID:  "admin",
+			},
+			Policy: AdminPolicy,
+		}, nil
+	case "developer":
+		return &types.APIPolicy{
+			APIPolicyMeta: &types.APIPolicyMeta{
+				Name: "developer",
+				UID:  "developer",
+			},
+			Policy: DeveloperPolicy,
+		}, nil
+	case "viewer":
+		return &types.APIPolicy{
+			APIPolicyMeta: &types.APIPolicyMeta{
+				Name: "viewer",
+				UID:  "viewer",
+			},
+			Policy: ViewerPolicy,
+		}, nil
+	default:
+		// look up the policy and make sure it exists
+		policyModel, err := policyRepo.ReadPolicy(projectID, uid)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("policy not found in project"),
+					http.StatusBadRequest,
+				)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		apiPolicy, err := policyModel.ToAPIPolicyType()
+
+		if err != nil {
+			return nil, apierrors.NewErrInternal(err)
+		}
+
+		return apiPolicy, nil
+	}
+}

+ 11 - 23
api/server/handlers/api_token/create.go

@@ -1,10 +1,10 @@
 package api_token
 
 import (
-	"errors"
-	"fmt"
 	"net/http"
+	"time"
 
+	"github.com/porter-dev/porter/api/server/authz/policy"
 	"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"
@@ -14,7 +14,6 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
-	"gorm.io/gorm"
 )
 
 type APITokenCreateHandler struct {
@@ -41,19 +40,15 @@ func (p *APITokenCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	// look up the policy and make sure it exists
-	policy, err := p.Repo().Policy().ReadPolicy(proj.ID, req.PolicyUID)
+	// if the expiry time is not set, set the expiry to 1 year
+	if req.ExpiresAt.IsZero() {
+		req.ExpiresAt = time.Now().Add(time.Hour * 24 * 365)
+	}
 
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-				fmt.Errorf("policy not found in project"),
-				http.StatusBadRequest,
-			))
-			return
-		}
+	apiPolicy, reqErr := policy.GetAPIPolicyFromUID(p.Repo().Policy(), proj.ID, req.PolicyUID)
 
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
 		return
 	}
 
@@ -85,8 +80,8 @@ func (p *APITokenCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		CreatedByUserID: user.ID,
 		Expiry:          &req.ExpiresAt,
 		Revoked:         false,
-		PolicyUID:       policy.UniqueID,
-		PolicyName:      policy.Name,
+		PolicyUID:       apiPolicy.UID,
+		PolicyName:      apiPolicy.Name,
 		Name:            req.Name,
 		SecretKey:       hashedToken,
 	}
@@ -98,13 +93,6 @@ func (p *APITokenCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	apiPolicy, err := policy.ToAPIPolicyType()
-
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	// generate porter jwt token
 	jwt, err := token.GetStoredTokenForAPI(user.ID, proj.ID, apiToken.UniqueID, secretKey)
 

+ 4 - 19
api/server/handlers/api_token/get.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/porter-dev/porter/api/server/authz/policy"
 	"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"
@@ -55,26 +56,10 @@ func (p *APITokenGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// look up the policy and make sure it exists
-	policy, err := p.Repo().Policy().ReadPolicy(proj.ID, token.PolicyUID)
+	apiPolicy, reqErr := policy.GetAPIPolicyFromUID(p.Repo().Policy(), proj.ID, token.PolicyUID)
 
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-				fmt.Errorf("policy no longer found in project"),
-				http.StatusBadRequest,
-			))
-			return
-		}
-
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	apiPolicy, err := policy.ToAPIPolicyType()
-
-	if err != nil {
-		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
 		return
 	}
 

+ 2 - 2
api/server/router/project.go

@@ -921,14 +921,14 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	//  GET /api/projects/{project_id}/policy -> policy.NewPolicyListHandler
+	//  GET /api/projects/{project_id}/policies -> policy.NewPolicyListHandler
 	policyListEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/policy",
+				RelativePath: relPath + "/policies",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,

+ 275 - 0
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -0,0 +1,275 @@
+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 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 CreateAPITokenForm from "./api-tokens/CreateAPITokenForm";
+import TokenList from "./api-tokens/TokenList";
+
+type Props = {};
+
+export type APITokenMeta = {
+  created_at: string;
+  updated_at: string;
+  expires_at: string;
+  id: string;
+  policy_name: string;
+  policy_uid: string;
+  name: string;
+};
+
+export type APIToken = APITokenMeta & {
+  token?: string;
+};
+
+const APITokensSection: React.FunctionComponent<Props> = ({}) => {
+  const { currentProject } = useContext(Context);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [apiTokens, setAPITokens] = useState<Array<APITokenMeta>>([]);
+  const [shouldCreate, setShouldCreate] = useState(false);
+
+  useEffect(() => {
+    api
+      .listAPITokens("<token>", {}, { project_id: currentProject.id })
+      .then(({ data }) => {
+        setAPITokens(data);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, shouldCreate]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (shouldCreate) {
+    return <CreateAPITokenForm onCreate={() => setShouldCreate(false)} />;
+  }
+
+  const getTokenList = () => {
+    return apiTokens.map((token) => {
+      return <div>{token.name}</div>;
+    });
+  };
+
+  return (
+    <>
+      <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} />
+      </TokenListWrapper>
+    </>
+  );
+};
+
+export default APITokensSection;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  margin-top: 23px;
+  justify-content: center;
+  background: #ffffff11;
+  border-radius: 5px;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const InputRowWrapper = styled.div`
+  width: 40%;
+`;
+
+const CopyButton = styled.div`
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin: 8px 0 8px 12px;
+  float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 120px;
+  cursor: pointer;
+  height: 30px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const NewLinkButton = styled(CopyButton)`
+  border: none;
+  width: auto;
+  float: none;
+  display: block;
+  margin: unset;
+  background-color: transparent;
+  :hover {
+    border: none;
+    background-color: transparent;
+  }
+`;
+
+const InviteButton = styled.div<{ disabled: boolean }>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 13px;
+  text-align: left;
+  float: left;
+  margin-left: 0;
+  justify-content: center;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? "#616FEEcc" : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  margin-bottom: 10px;
+`;
+
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  :hover {
+    cursor: pointer;
+  }
+`;
+
+const Invalid = styled.div`
+  color: #f5cb42;
+  margin-left: 15px;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;
+
+const TokenListWrapper = styled.div`
+  width: 60%;
+  min-width: 400px;
+`;

+ 9 - 0
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -12,6 +12,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
 import { getQueryParam } from "shared/routing";
 import BillingPage from "./BillingPage";
+import APITokensSection from "./APITokensSection";
 
 type PropsType = RouteComponentProps & WithAuthProps & {};
 
@@ -72,6 +73,12 @@ class ProjectSettings extends Component<PropsType, StateType> {
           label: "Billing",
         });
       }
+
+      tabOptions.push({
+        value: "api-tokens",
+        label: "API Tokens",
+      });
+
       tabOptions.push({
         value: "additional-settings",
         label: "Additional Settings",
@@ -100,6 +107,8 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
+    } else if (this.state.currentTab === "api-tokens") {
+      return <APITokensSection />;
     } else {
       return (
         <>

+ 393 - 0
dashboard/src/main/home/project-settings/api-tokens/CreateAPITokenForm.tsx

@@ -0,0 +1,393 @@
+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 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";
+
+type Props = {
+  onCreate: () => void;
+};
+
+const getDateValue = (option: string): string => {
+  let now = new Date();
+
+  switch (option) {
+    case "oneday":
+      return new Date(new Date().setHours(now.getHours() + 24)).toISOString();
+    case "threedays":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 3)
+      ).toISOString();
+    case "sevendays":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 7)
+      ).toISOString();
+    case "thirtydays":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 30)
+      ).toISOString();
+    case "oneyear":
+      return new Date(
+        new Date().setHours(now.getHours() + 24 * 365)
+      ).toISOString();
+    default:
+      return "";
+  }
+};
+
+export const getDateOptions = (): { value: string; label: string }[] => {
+  return [
+    {
+      label: "1 Day",
+      value: "oneday",
+    },
+    {
+      label: "3 Days",
+      value: "threedays",
+    },
+    {
+      label: "7 Days",
+      value: "sevendays",
+    },
+    {
+      label: "30 Days",
+      value: "thirtydays",
+    },
+    {
+      label: "1 Year",
+      value: "oneyear",
+    },
+  ];
+};
+
+const CreateAPITokenForm: React.FunctionComponent<Props> = ({ onCreate }) => {
+  const { currentProject } = useContext(Context);
+  const [apiTokenName, setAPITokenName] = useState("");
+  const dateOptions = getDateOptions();
+  const [expiration, setExpiration] = useState("thirtydays");
+  const [policy, setPolicy] = useState("developer");
+  const [createdToken, setCreatedToken] = useState<APIToken>(null);
+  const [copied, setCopied] = useState(false);
+
+  const createToken = () => {
+    api
+      .createAPIToken(
+        "<token>",
+        {
+          name: apiTokenName,
+          expires_at: getDateValue(expiration),
+          policy_uid: policy,
+        },
+        { project_id: currentProject.id }
+      )
+      .then(({ data }) => {
+        setCreatedToken(data);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  };
+
+  if (createdToken != null) {
+    return (
+      <CreateTokenWrapper>
+        <Heading isAtTop={true}>API token created successfully!</Heading>
+        <Helper>
+          Please copy this token and store it in a secure location. This token
+          will only be shown once:
+        </Helper>
+        <TokenDisplayBlock>
+          <CodeBlock>{createdToken.token}</CodeBlock>
+          <CopyToClipboard
+            as={CopyTokenButton}
+            text={createdToken.token}
+            onSuccess={() => setCopied(true)}
+          >
+            <i className="material-icons-outlined">
+              {copied ? "check" : "content_copy"}
+            </i>
+          </CopyToClipboard>
+        </TokenDisplayBlock>
+        <SaveButton text="Continue" onClick={onCreate} />
+      </CreateTokenWrapper>
+    );
+  }
+
+  return (
+    <CreateTokenWrapper>
+      <Heading isAtTop={true}>Create API Token</Heading>
+      <InputRow
+        value={apiTokenName}
+        type="text"
+        setValue={(newName: string) => setAPITokenName(newName)}
+        label="API Token Name"
+        width="100%"
+        placeholder="ex: api-token-admin"
+        isRequired={true}
+      />
+      <SelectRow
+        value={expiration}
+        label="Expiration"
+        setActiveValue={setExpiration}
+        options={dateOptions}
+      />
+      <SelectRow
+        value={policy}
+        label="Role"
+        setActiveValue={setPolicy}
+        options={[
+          {
+            label: "Admin",
+            value: "admin",
+          },
+          {
+            label: "Developer",
+            value: "developer",
+          },
+          {
+            label: "Viewer",
+            value: "viewer",
+          },
+        ]}
+      />
+      <SaveButton text="Create Token" onClick={createToken} />
+    </CreateTokenWrapper>
+  );
+};
+
+export default CreateAPITokenForm;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  margin-top: 23px;
+  justify-content: center;
+  background: #ffffff11;
+  border-radius: 5px;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const CreateTokenWrapper = styled.div`
+  width: 40%;
+  min-width: 500px;
+  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`
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
+  margin: 8px 0 8px 12px;
+  float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 120px;
+  cursor: pointer;
+  height: 30px;
+  border-radius: 5px;
+  border: 1px solid #ffffff20;
+  background-color: #ffffff10;
+  overflow: hidden;
+  transition: all 0.1s ease-out;
+  :hover {
+    border: 1px solid #ffffff66;
+    background-color: #ffffff20;
+  }
+`;
+
+const NewLinkButton = styled(CopyButton)`
+  border: none;
+  width: auto;
+  float: none;
+  display: block;
+  margin: unset;
+  background-color: transparent;
+  :hover {
+    border: none;
+    background-color: transparent;
+  }
+`;
+
+const InviteButton = styled.div<{ disabled: boolean }>`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  display: flex;
+  align-items: center;
+  padding: 0 15px;
+  margin-top: 13px;
+  text-align: left;
+  float: left;
+  margin-left: 0;
+  justify-content: center;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? "#616FEEcc" : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  margin-bottom: 10px;
+`;
+
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  :hover {
+    cursor: pointer;
+  }
+`;
+
+const Invalid = styled.div`
+  color: #f5cb42;
+  margin-left: 15px;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;
+
+const TokenDisplayBlock = styled.div`
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  background-color: #1b1d26;
+`;
+
+const CopyTokenButton = styled.div`
+  height: 30px;
+  padding: 10px;
+  cursor: pointer;
+
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
+  }
+`;
+
+const CodeBlock = styled.div`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  user-select: text;
+  overflow: auto;
+  padding: 10px;
+  white-space: nowrap;
+  border-right: 10px solid #1b1d26;
+`;

+ 81 - 0
dashboard/src/main/home/project-settings/api-tokens/TokenList.tsx

@@ -0,0 +1,81 @@
+import React from "react";
+import styled from "styled-components";
+import { APITokenMeta } from "../APITokensSection";
+
+type Props = {
+  tokens: APITokenMeta[];
+};
+
+const TokenList: React.FunctionComponent<Props> = (props) => {
+  return (
+    <>
+      {props.tokens.map((token) => {
+        return (
+          <PreviewRow key={token.id}>
+            <Flex>
+              <i className="material-icons">token</i>
+              {token.name}
+            </Flex>
+            <Right>Expires at {token.expires_at}</Right>
+          </PreviewRow>
+        );
+      })}
+    </>
+  );
+};
+
+export default TokenList;
+
+const PreviewRow = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 12px 15px;
+  color: #ffffff55;
+  background: #ffffff01;
+  border: 1px solid #aaaabb;
+  justify-content: space-between;
+  font-size: 13px;
+  border-radius: 5px;
+  cursor: pointer;
+  margin: 16px 0;
+  :hover {
+    background: #ffffff10;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  color: #ffffff;
+  align-items: center;
+  > i {
+    color: #aaaabb;
+    font-size: 20px;
+    margin-right: 10px;
+  }
+`;
+
+const Right = styled.div`
+  text-align: right;
+`;
+
+const CreateNewRow = styled(PreviewRow)`
+  background: none;
+`;
+
+const CreateNewRowLink = styled.a`
+  background: none;
+  display: flex;
+  align-items: center;
+  padding: 12px 15px;
+  color: #ffffff55;
+  background: #ffffff01;
+  border: 1px solid #aaaabb;
+  justify-content: space-between;
+  font-size: 13px;
+  border-radius: 5px;
+  cursor: pointer;
+  margin: 16px 0;
+  :hover {
+    background: #ffffff10;
+  }
+`;

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

@@ -1323,6 +1323,20 @@ const stopJob = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/stop`;
 });
 
+const listAPITokens = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/api_token`
+);
+
+const createAPIToken = baseApi<
+  {
+    name: string;
+    policy_uid: string;
+    expires_at?: string;
+  },
+  { project_id: number }
+>("POST", ({ project_id }) => `/api/projects/${project_id}/api_token`);
+
 const getAvailableRoles = baseApi<{}, { project_id: number }>(
   "GET",
   ({ project_id }) => `/api/projects/${project_id}/roles`
@@ -1671,6 +1685,8 @@ export default {
   deleteJob,
   stopJob,
   updateInvite,
+  listAPITokens,
+  createAPIToken,
   getAvailableRoles,
   getCollaborators,
   updateCollaborator,