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
20 changes: 18 additions & 2 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key))
}

type ConvertedAPIKey struct {
codersdk.APIKey
Username string `json:"username"`
}

// @Summary Get user tokens
// @ID get-user-tokens
// @Security CoderSessionToken
Expand Down Expand Up @@ -216,9 +221,20 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
return
}

var apiKeys []codersdk.APIKey
var apiKeys []ConvertedAPIKey
for _, key := range keys {
apiKeys = append(apiKeys, convertAPIKey(key))
user, err := api.Database.GetUserByID(ctx, key.UserID)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use GetUsersByIDs here instead, otherwise, you could execute hundreds of separate queries if there are hundreds of users.

Generally, we should never query in for loops!

if err != nil {
apiKeys = append(apiKeys, ConvertedAPIKey{
APIKey: convertAPIKey(key),
Username: "",
})
} else {
apiKeys = append(apiKeys, ConvertedAPIKey{
APIKey: convertAPIKey(key),
Username: user.Username,
})
}
}

httpapi.Write(ctx, rw, http.StatusOK, apiKeys)
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
27 changes: 22 additions & 5 deletions site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@ const lastUsedOrNever = (lastUsed: string) => {
return now.isBefore(t.add(100, "year")) ? t.fromNow() : "Never"
}

interface ConvertedAPIKey extends APIKey {
username: string
}

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

return (
<Stack>
Expand All @@ -57,10 +64,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 +112,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
Original file line number Diff line number Diff line change
@@ -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)
}}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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",
},
},
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"
export { TokensSwitch } from "./TokensSwitch"
Loading