mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Vendored
+1
@@ -4,6 +4,7 @@
|
||||
"agentsdk",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"authcheck",
|
||||
"autostop",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
|
||||
+4
-17
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user