Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 1, 2023
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"agentsdk",
"apps",
"ASKPASS",
"authcheck",
"autostop",
"awsidentity",
"bodyclose",
Expand Down
21 changes: 4 additions & 17 deletions cli/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"strings"
"time"

"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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)
Expand Down
25 changes: 23 additions & 2 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions codersdk/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions site/src/i18n/en/tokensPage.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand All @@ -13,6 +13,7 @@
"id": "ID",
"createdAt": "Created At",
"lastUsed": "Last Used",
"expiresAt": "Expires At"
"expiresAt": "Expires At",
"owner": "Owner"
}
}
107 changes: 40 additions & 67 deletions site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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}
Expand All @@ -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,
},
},
}))

Expand Down
25 changes: 19 additions & 6 deletions site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -24,7 +24,8 @@ const lastUsedOrNever = (lastUsed: string) => {
}

export interface TokensPageViewProps {
tokens?: APIKey[]
tokens?: APIKeyWithOwner[]
viewAllTokens: boolean
getTokensError?: Error | unknown
isLoading: boolean
hasLoaded: boolean
Expand All @@ -36,6 +37,7 @@ export const TokensPageView: FC<
React.PropsWithChildren<TokensPageViewProps>
> = ({
tokens,
viewAllTokens,
getTokensError,
isLoading,
hasLoaded,
Expand All @@ -44,6 +46,7 @@ export const TokensPageView: FC<
}) => {
const theme = useTheme()
const { t } = useTranslation("tokensPage")
const colWidth = viewAllTokens ? "20%" : "25%"

return (
<Stack>
Expand All @@ -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>
Expand Down Expand Up @@ -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
Expand Down
Loading