feat: add 'Show all tokens' toggle for owners (#6325)

* add tokens switch

* reorged TokensPage

* using Trans component for description

* using Trans component on DeleteDialog

* add owner col

* simplify hook return

* lint

* type for response

* PR feedback

* fix lint
This commit is contained in:
Kira Pilot
2023-03-01 08:35:55 -08:00
committed by GitHub
parent b56e1bb002
commit 6304bfb5c0
13 changed files with 238 additions and 100 deletions
+1
View File
@@ -4,6 +4,7 @@
"agentsdk",
"apps",
"ASKPASS",
"authcheck",
"autostop",
"awsidentity",
"bodyclose",
+4 -17
View File
@@ -6,7 +6,6 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
@@ -99,16 +98,14 @@ type tokenListRow struct {
Owner string `json:"-" table:"owner"`
}
func tokenListRowFromToken(token codersdk.APIKey, usersByID map[uuid.UUID]codersdk.User) tokenListRow {
user := usersByID[token.UserID]
func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow {
return tokenListRow{
APIKey: token,
APIKey: token.APIKey,
ID: token.ID,
LastUsed: token.LastUsed,
ExpiresAt: token.ExpiresAt,
CreatedAt: token.CreatedAt,
Owner: user.Username,
Owner: token.Username,
}
}
@@ -150,20 +147,10 @@ func listTokens() *cobra.Command {
))
}
userRes, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
if err != nil {
return err
}
usersByID := map[uuid.UUID]codersdk.User{}
for _, user := range userRes.Users {
usersByID[user.ID] = user
}
displayTokens = make([]tokenListRow, len(tokens))
for i, token := range tokens {
displayTokens[i] = tokenListRowFromToken(token, usersByID)
displayTokens[i] = tokenListRowFromToken(token)
}
out, err := formatter.Format(cmd.Context(), displayTokens)
+23 -2
View File
@@ -216,9 +216,30 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
return
}
var apiKeys []codersdk.APIKey
var userIds []uuid.UUID
for _, key := range keys {
apiKeys = append(apiKeys, convertAPIKey(key))
userIds = append(userIds, key.UserID)
}
users, _ := api.Database.GetUsersByIDs(ctx, userIds)
usersByID := map[uuid.UUID]database.User{}
for _, user := range users {
usersByID[user.ID] = user
}
var apiKeys []codersdk.APIKeyWithOwner
for _, key := range keys {
if user, exists := usersByID[key.UserID]; exists {
apiKeys = append(apiKeys, codersdk.APIKeyWithOwner{
APIKey: convertAPIKey(key),
Username: user.Username,
})
} else {
apiKeys = append(apiKeys, codersdk.APIKeyWithOwner{
APIKey: convertAPIKey(key),
Username: "",
})
}
}
httpapi.Write(ctx, rw, http.StatusOK, apiKeys)
+7 -2
View File
@@ -90,6 +90,11 @@ type TokensFilter struct {
IncludeAll bool `json:"include_all"`
}
type APIKeyWithOwner struct {
APIKey
Username string `json:"username"`
}
// asRequestOption returns a function that can be used in (*Client).Request.
// It modifies the request query parameters.
func (f TokensFilter) asRequestOption() RequestOption {
@@ -101,7 +106,7 @@ func (f TokensFilter) asRequestOption() RequestOption {
}
// Tokens list machine API keys.
func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]APIKey, error) {
func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]APIKeyWithOwner, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil, filter.asRequestOption())
if err != nil {
return nil, err
@@ -110,7 +115,7 @@ func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter)
if res.StatusCode > http.StatusOK {
return nil, ReadBodyAsError(res)
}
apiKey := []APIKey{}
apiKey := []APIKeyWithOwner{}
return apiKey, json.NewDecoder(res.Body).Decode(&apiKey)
}
+2 -2
View File
@@ -142,8 +142,8 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
export const getTokens = async (
params: TypesGen.TokensFilter,
): Promise<TypesGen.APIKey[]> => {
const response = await axios.get<TypesGen.APIKey[]>(
): Promise<TypesGen.APIKeyWithOwner[]> => {
const response = await axios.get<TypesGen.APIKeyWithOwner[]>(
`/api/v2/users/me/keys/tokens`,
{
params,
+5
View File
@@ -13,6 +13,11 @@ export interface APIKey {
readonly lifetime_seconds: number
}
// From codersdk/apikey.go
export interface APIKeyWithOwner extends APIKey {
readonly username: string
}
// From codersdk/licenses.go
export interface AddLicenseRequest {
readonly license: string
+4 -3
View File
@@ -1,10 +1,10 @@
{
"title": "Tokens",
"description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ",
"description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the <1>{{cliCreateCommand}}</1> command.",
"emptyState": "No tokens found",
"deleteToken": {
"delete": "Delete Token",
"deleteCaption": "Are you sure you want to delete this token?",
"deleteCaption": "Are you sure you want to delete this token?<br/><br/><4>{{tokenId}}</4>",
"deleteSuccess": "Token has been deleted",
"deleteFailure": "Failed to delete token"
},
@@ -13,6 +13,7 @@
"id": "ID",
"createdAt": "Created At",
"lastUsed": "Last Used",
"expiresAt": "Expires At"
"expiresAt": "Expires At",
"owner": "Owner"
}
}
@@ -1,20 +1,28 @@
import { FC, PropsWithChildren, useState } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import { Section } from "components/SettingsLayout/Section"
import { TokensPageView } from "./TokensPageView"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Typography } from "components/Typography/Typography"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { useTranslation } from "react-i18next"
import { useTokensData, useDeleteToken } from "./hooks"
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils"
import { getErrorMessage } from "api/errors"
import { useTranslation, Trans } from "react-i18next"
import { useTokensData, useCheckTokenPermissions } from "./hooks"
import { TokensSwitch, ConfirmDeleteDialog } from "./components"
export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
const styles = useStyles()
const { t } = useTranslation("tokensPage")
const cliCreateCommand = "coder tokens create"
const description = (
<Trans t={t} i18nKey="description" values={{ cliCreateCommand }}>
Tokens are used to authenticate with the Coder API. You can create a token
with the Coder CLI using the <code>{{ cliCreateCommand }}</code> command.
</Trans>
)
const [tokenIdToDelete, setTokenIdToDelete] = useState<string | undefined>(
undefined,
)
const [viewAllTokens, setViewAllTokens] = useState<boolean>(false)
const { data: perms } = useCheckTokenPermissions()
const {
data: tokens,
@@ -23,44 +31,25 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
isFetched,
queryKey,
} = useTokensData({
include_all: true,
include_all: viewAllTokens,
})
const { mutate: deleteToken, isLoading: isDeleting } =
useDeleteToken(queryKey)
const onDeleteSuccess = () => {
displaySuccess(t("deleteToken.deleteSuccess"))
setTokenIdToDelete(undefined)
}
const onDeleteError = (error: unknown) => {
const message = getErrorMessage(error, t("deleteToken.deleteFailure"))
displayError(message)
setTokenIdToDelete(undefined)
}
const description = (
<>
{t("description")}{" "}
<code className={styles.code}>coder tokens create</code> command.
</>
)
const content = (
<Typography>
{t("deleteToken.deleteCaption")}
<br />
<br />
{tokenIdToDelete}
</Typography>
)
return (
<>
<Section title={t("title")} description={description} layout="fluid">
<Section
title={t("title")}
className={styles.section}
description={description}
layout="fluid"
>
<TokensSwitch
hasReadAll={perms?.readAllApiKeys ?? false}
viewAllTokens={viewAllTokens}
setViewAllTokens={setViewAllTokens}
/>
<TokensPageView
tokens={tokens}
viewAllTokens={viewAllTokens}
isLoading={isFetching}
hasLoaded={isFetched}
getTokensError={getTokensError}
@@ -69,40 +58,24 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
}}
/>
</Section>
<ConfirmDialog
title={t("deleteToken.delete")}
description={content}
open={Boolean(tokenIdToDelete) || isDeleting}
confirmLoading={isDeleting}
onConfirm={() => {
if (!tokenIdToDelete) {
return
}
deleteToken(tokenIdToDelete, {
onError: onDeleteError,
onSuccess: onDeleteSuccess,
})
}}
onClose={() => {
setTokenIdToDelete(undefined)
}}
<ConfirmDeleteDialog
queryKey={queryKey}
tokenId={tokenIdToDelete}
setTokenId={setTokenIdToDelete}
/>
</>
)
}
const useStyles = makeStyles((theme) => ({
code: {
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
},
formRow: {
justifyContent: "end",
marginBottom: "10px",
section: {
"& code": {
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
},
},
}))
@@ -5,7 +5,6 @@ import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import { APIKey } from "api/typesGenerated"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Stack } from "components/Stack/Stack"
import { TableEmpty } from "components/TableEmpty/TableEmpty"
@@ -16,6 +15,7 @@ import { FC } from "react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import IconButton from "@material-ui/core/IconButton/IconButton"
import { useTranslation } from "react-i18next"
import { APIKeyWithOwner } from "api/typesGenerated"
const lastUsedOrNever = (lastUsed: string) => {
const t = dayjs(lastUsed)
@@ -24,7 +24,8 @@ const lastUsedOrNever = (lastUsed: string) => {
}
export interface TokensPageViewProps {
tokens?: APIKey[]
tokens?: APIKeyWithOwner[]
viewAllTokens: boolean
getTokensError?: Error | unknown
isLoading: boolean
hasLoaded: boolean
@@ -36,6 +37,7 @@ export const TokensPageView: FC<
React.PropsWithChildren<TokensPageViewProps>
> = ({
tokens,
viewAllTokens,
getTokensError,
isLoading,
hasLoaded,
@@ -44,6 +46,7 @@ export const TokensPageView: FC<
}) => {
const theme = useTheme()
const { t } = useTranslation("tokensPage")
const colWidth = viewAllTokens ? "20%" : "25%"
return (
<Stack>
@@ -57,10 +60,13 @@ export const TokensPageView: FC<
<Table>
<TableHead>
<TableRow>
<TableCell width="25%">{t("table.id")}</TableCell>
<TableCell width="25%">{t("table.createdAt")}</TableCell>
<TableCell width="25%">{t("table.lastUsed")}</TableCell>
<TableCell width="25%">{t("table.expiresAt")}</TableCell>
<TableCell width={colWidth}>{t("table.id")}</TableCell>
<TableCell width={colWidth}>{t("table.createdAt")}</TableCell>
<TableCell width={colWidth}>{t("table.lastUsed")}</TableCell>
<TableCell width={colWidth}>{t("table.expiresAt")}</TableCell>
{viewAllTokens && (
<TableCell width="20%">{t("table.owner")}</TableCell>
)}
<TableCell width="0%"></TableCell>
</TableRow>
</TableHead>
@@ -102,6 +108,13 @@ export const TokensPageView: FC<
{dayjs(token.expires_at).fromNow()}
</span>
</TableCell>
{viewAllTokens && (
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.username}
</span>
</TableCell>
)}
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
<IconButton
@@ -0,0 +1,58 @@
import { FC } from "react"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { useTranslation, Trans } from "react-i18next"
import { useDeleteToken } from "../hooks"
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils"
import { getErrorMessage } from "api/errors"
export const ConfirmDeleteDialog: FC<{
queryKey: (string | boolean)[]
tokenId: string | undefined
setTokenId: (arg: string | undefined) => void
}> = ({ queryKey, tokenId, setTokenId }) => {
const { t } = useTranslation("tokensPage")
const description = (
<Trans t={t} i18nKey="deleteToken.deleteCaption" values={{ tokenId }}>
Are you sure you want to delete this token?
<br />
<br />
{{ tokenId }}
</Trans>
)
const { mutate: deleteToken, isLoading: isDeleting } =
useDeleteToken(queryKey)
const onDeleteSuccess = () => {
displaySuccess(t("deleteToken.deleteSuccess"))
setTokenId(undefined)
}
const onDeleteError = (error: unknown) => {
const message = getErrorMessage(error, t("deleteToken.deleteFailure"))
displayError(message)
setTokenId(undefined)
}
return (
<ConfirmDialog
title={t("deleteToken.delete")}
description={description}
open={Boolean(tokenId) || isDeleting}
confirmLoading={isDeleting}
onConfirm={() => {
if (!tokenId) {
return
}
deleteToken(tokenId, {
onError: onDeleteError,
onSuccess: onDeleteSuccess,
})
}}
onClose={() => {
setTokenId(undefined)
}}
/>
)
}
@@ -0,0 +1,48 @@
import { FC } from "react"
import Switch from "@material-ui/core/Switch"
import FormGroup from "@material-ui/core/FormGroup"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { useTranslation } from "react-i18next"
export const TokensSwitch: FC<{
hasReadAll: boolean
viewAllTokens: boolean
setViewAllTokens: (arg: boolean) => void
}> = ({ hasReadAll, viewAllTokens, setViewAllTokens }) => {
const styles = useStyles()
const { t } = useTranslation("tokensPage")
return (
<FormGroup row className={styles.formRow}>
{hasReadAll && (
<FormControlLabel
control={
<Switch
className={styles.selectAllSwitch}
checked={viewAllTokens}
onChange={() => setViewAllTokens(!viewAllTokens)}
name="viewAllTokens"
color="primary"
/>
}
label={t("toggleLabel")}
/>
)}
</FormGroup>
)
}
const useStyles = makeStyles(() => ({
formRow: {
justifyContent: "end",
marginBottom: "10px",
},
selectAllSwitch: {
// decrease the hover state on the switch
// so that it isn't hidden behind the container
"& .MuiIconButton-root": {
padding: "8px",
},
},
}))
@@ -0,0 +1,2 @@
export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"
export { TokensSwitch } from "./TokensSwitch"
@@ -4,9 +4,32 @@ import {
useQueryClient,
QueryKey,
} from "@tanstack/react-query"
import { getTokens, deleteAPIKey } from "api/api"
import { getTokens, deleteAPIKey, checkAuthorization } from "api/api"
import { TokensFilter } from "api/typesGenerated"
// Owners have the ability to read all API tokens,
// whereas members can only see the tokens they have created.
// We check permissions here to determine whether to display the
// 'View All' switch on the TokensPage.
export const useCheckTokenPermissions = () => {
const queryKey = ["auth"]
const params = {
checks: {
readAllApiKeys: {
object: {
resource_type: "api_key",
},
action: "read",
},
},
}
return useQuery({
queryKey,
queryFn: () => checkAuthorization(params),
})
}
// Load all tokens
export const useTokensData = ({ include_all }: TokensFilter) => {
const queryKey = ["tokens", include_all]
const result = useQuery({
@@ -23,6 +46,7 @@ export const useTokensData = ({ include_all }: TokensFilter) => {
}
}
// Delete a token
export const useDeleteToken = (queryKey: QueryKey) => {
const queryClient = useQueryClient()