Skip to content

Commit 94bf3e8

Browse files
Kira-Pilotdeansheather
authored andcommitted
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
1 parent 9bc8a3b commit 94bf3e8

File tree

13 files changed

+238
-100
lines changed

13 files changed

+238
-100
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"agentsdk",
55
"apps",
66
"ASKPASS",
7+
"authcheck",
78
"autostop",
89
"awsidentity",
910
"bodyclose",

cli/tokens.go

+4-17
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"strings"
77
"time"
88

9-
"github.com/google/uuid"
109
"github.com/spf13/cobra"
1110
"golang.org/x/exp/slices"
1211
"golang.org/x/xerrors"
@@ -99,16 +98,14 @@ type tokenListRow struct {
9998
Owner string `json:"-" table:"owner"`
10099
}
101100

102-
func tokenListRowFromToken(token codersdk.APIKey, usersByID map[uuid.UUID]codersdk.User) tokenListRow {
103-
user := usersByID[token.UserID]
104-
101+
func tokenListRowFromToken(token codersdk.APIKeyWithOwner) tokenListRow {
105102
return tokenListRow{
106-
APIKey: token,
103+
APIKey: token.APIKey,
107104
ID: token.ID,
108105
LastUsed: token.LastUsed,
109106
ExpiresAt: token.ExpiresAt,
110107
CreatedAt: token.CreatedAt,
111-
Owner: user.Username,
108+
Owner: token.Username,
112109
}
113110
}
114111

@@ -150,20 +147,10 @@ func listTokens() *cobra.Command {
150147
))
151148
}
152149

153-
userRes, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
154-
if err != nil {
155-
return err
156-
}
157-
158-
usersByID := map[uuid.UUID]codersdk.User{}
159-
for _, user := range userRes.Users {
160-
usersByID[user.ID] = user
161-
}
162-
163150
displayTokens = make([]tokenListRow, len(tokens))
164151

165152
for i, token := range tokens {
166-
displayTokens[i] = tokenListRowFromToken(token, usersByID)
153+
displayTokens[i] = tokenListRowFromToken(token)
167154
}
168155

169156
out, err := formatter.Format(cmd.Context(), displayTokens)

coderd/apikey.go

+23-2
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,30 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
216216
return
217217
}
218218

219-
var apiKeys []codersdk.APIKey
219+
var userIds []uuid.UUID
220220
for _, key := range keys {
221-
apiKeys = append(apiKeys, convertAPIKey(key))
221+
userIds = append(userIds, key.UserID)
222+
}
223+
224+
users, _ := api.Database.GetUsersByIDs(ctx, userIds)
225+
usersByID := map[uuid.UUID]database.User{}
226+
for _, user := range users {
227+
usersByID[user.ID] = user
228+
}
229+
230+
var apiKeys []codersdk.APIKeyWithOwner
231+
for _, key := range keys {
232+
if user, exists := usersByID[key.UserID]; exists {
233+
apiKeys = append(apiKeys, codersdk.APIKeyWithOwner{
234+
APIKey: convertAPIKey(key),
235+
Username: user.Username,
236+
})
237+
} else {
238+
apiKeys = append(apiKeys, codersdk.APIKeyWithOwner{
239+
APIKey: convertAPIKey(key),
240+
Username: "",
241+
})
242+
}
222243
}
223244

224245
httpapi.Write(ctx, rw, http.StatusOK, apiKeys)

codersdk/apikey.go

+7-2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ type TokensFilter struct {
9090
IncludeAll bool `json:"include_all"`
9191
}
9292

93+
type APIKeyWithOwner struct {
94+
APIKey
95+
Username string `json:"username"`
96+
}
97+
9398
// asRequestOption returns a function that can be used in (*Client).Request.
9499
// It modifies the request query parameters.
95100
func (f TokensFilter) asRequestOption() RequestOption {
@@ -101,7 +106,7 @@ func (f TokensFilter) asRequestOption() RequestOption {
101106
}
102107

103108
// Tokens list machine API keys.
104-
func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]APIKey, error) {
109+
func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]APIKeyWithOwner, error) {
105110
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil, filter.asRequestOption())
106111
if err != nil {
107112
return nil, err
@@ -110,7 +115,7 @@ func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter)
110115
if res.StatusCode > http.StatusOK {
111116
return nil, ReadBodyAsError(res)
112117
}
113-
apiKey := []APIKey{}
118+
apiKey := []APIKeyWithOwner{}
114119
return apiKey, json.NewDecoder(res.Body).Decode(&apiKey)
115120
}
116121

site/src/api/api.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
142142

143143
export const getTokens = async (
144144
params: TypesGen.TokensFilter,
145-
): Promise<TypesGen.APIKey[]> => {
146-
const response = await axios.get<TypesGen.APIKey[]>(
145+
): Promise<TypesGen.APIKeyWithOwner[]> => {
146+
const response = await axios.get<TypesGen.APIKeyWithOwner[]>(
147147
`/api/v2/users/me/keys/tokens`,
148148
{
149149
params,

site/src/api/typesGenerated.ts

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export interface APIKey {
1313
readonly lifetime_seconds: number
1414
}
1515

16+
// From codersdk/apikey.go
17+
export interface APIKeyWithOwner extends APIKey {
18+
readonly username: string
19+
}
20+
1621
// From codersdk/licenses.go
1722
export interface AddLicenseRequest {
1823
readonly license: string

site/src/i18n/en/tokensPage.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"title": "Tokens",
3-
"description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ",
3+
"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.",
44
"emptyState": "No tokens found",
55
"deleteToken": {
66
"delete": "Delete Token",
7-
"deleteCaption": "Are you sure you want to delete this token?",
7+
"deleteCaption": "Are you sure you want to delete this token?<br/><br/><4>{{tokenId}}</4>",
88
"deleteSuccess": "Token has been deleted",
99
"deleteFailure": "Failed to delete token"
1010
},
@@ -13,6 +13,7 @@
1313
"id": "ID",
1414
"createdAt": "Created At",
1515
"lastUsed": "Last Used",
16-
"expiresAt": "Expires At"
16+
"expiresAt": "Expires At",
17+
"owner": "Owner"
1718
}
1819
}

site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx

+40-67
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import { FC, PropsWithChildren, useState } from "react"
2-
import { Section } from "../../../components/SettingsLayout/Section"
2+
import { Section } from "components/SettingsLayout/Section"
33
import { TokensPageView } from "./TokensPageView"
4-
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
5-
import { Typography } from "components/Typography/Typography"
64
import makeStyles from "@material-ui/core/styles/makeStyles"
7-
import { useTranslation } from "react-i18next"
8-
import { useTokensData, useDeleteToken } from "./hooks"
9-
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils"
10-
import { getErrorMessage } from "api/errors"
5+
import { useTranslation, Trans } from "react-i18next"
6+
import { useTokensData, useCheckTokenPermissions } from "./hooks"
7+
import { TokensSwitch, ConfirmDeleteDialog } from "./components"
118

129
export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
1310
const styles = useStyles()
1411
const { t } = useTranslation("tokensPage")
12+
13+
const cliCreateCommand = "coder tokens create"
14+
const description = (
15+
<Trans t={t} i18nKey="description" values={{ cliCreateCommand }}>
16+
Tokens are used to authenticate with the Coder API. You can create a token
17+
with the Coder CLI using the <code>{{ cliCreateCommand }}</code> command.
18+
</Trans>
19+
)
20+
1521
const [tokenIdToDelete, setTokenIdToDelete] = useState<string | undefined>(
1622
undefined,
1723
)
24+
const [viewAllTokens, setViewAllTokens] = useState<boolean>(false)
25+
const { data: perms } = useCheckTokenPermissions()
1826

1927
const {
2028
data: tokens,
@@ -23,44 +31,25 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
2331
isFetched,
2432
queryKey,
2533
} = useTokensData({
26-
include_all: true,
34+
include_all: viewAllTokens,
2735
})
2836

29-
const { mutate: deleteToken, isLoading: isDeleting } =
30-
useDeleteToken(queryKey)
31-
32-
const onDeleteSuccess = () => {
33-
displaySuccess(t("deleteToken.deleteSuccess"))
34-
setTokenIdToDelete(undefined)
35-
}
36-
37-
const onDeleteError = (error: unknown) => {
38-
const message = getErrorMessage(error, t("deleteToken.deleteFailure"))
39-
displayError(message)
40-
setTokenIdToDelete(undefined)
41-
}
42-
43-
const description = (
44-
<>
45-
{t("description")}{" "}
46-
<code className={styles.code}>coder tokens create</code> command.
47-
</>
48-
)
49-
50-
const content = (
51-
<Typography>
52-
{t("deleteToken.deleteCaption")}
53-
<br />
54-
<br />
55-
{tokenIdToDelete}
56-
</Typography>
57-
)
58-
5937
return (
6038
<>
61-
<Section title={t("title")} description={description} layout="fluid">
39+
<Section
40+
title={t("title")}
41+
className={styles.section}
42+
description={description}
43+
layout="fluid"
44+
>
45+
<TokensSwitch
46+
hasReadAll={perms?.readAllApiKeys ?? false}
47+
viewAllTokens={viewAllTokens}
48+
setViewAllTokens={setViewAllTokens}
49+
/>
6250
<TokensPageView
6351
tokens={tokens}
52+
viewAllTokens={viewAllTokens}
6453
isLoading={isFetching}
6554
hasLoaded={isFetched}
6655
getTokensError={getTokensError}
@@ -69,40 +58,24 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
6958
}}
7059
/>
7160
</Section>
72-
73-
<ConfirmDialog
74-
title={t("deleteToken.delete")}
75-
description={content}
76-
open={Boolean(tokenIdToDelete) || isDeleting}
77-
confirmLoading={isDeleting}
78-
onConfirm={() => {
79-
if (!tokenIdToDelete) {
80-
return
81-
}
82-
deleteToken(tokenIdToDelete, {
83-
onError: onDeleteError,
84-
onSuccess: onDeleteSuccess,
85-
})
86-
}}
87-
onClose={() => {
88-
setTokenIdToDelete(undefined)
89-
}}
61+
<ConfirmDeleteDialog
62+
queryKey={queryKey}
63+
tokenId={tokenIdToDelete}
64+
setTokenId={setTokenIdToDelete}
9065
/>
9166
</>
9267
)
9368
}
9469

9570
const useStyles = makeStyles((theme) => ({
96-
code: {
97-
background: theme.palette.divider,
98-
fontSize: 12,
99-
padding: "2px 4px",
100-
color: theme.palette.text.primary,
101-
borderRadius: 2,
102-
},
103-
formRow: {
104-
justifyContent: "end",
105-
marginBottom: "10px",
71+
section: {
72+
"& code": {
73+
background: theme.palette.divider,
74+
fontSize: 12,
75+
padding: "2px 4px",
76+
color: theme.palette.text.primary,
77+
borderRadius: 2,
78+
},
10679
},
10780
}))
10881

site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx

+19-6
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import TableCell from "@material-ui/core/TableCell"
55
import TableContainer from "@material-ui/core/TableContainer"
66
import TableHead from "@material-ui/core/TableHead"
77
import TableRow from "@material-ui/core/TableRow"
8-
import { APIKey } from "api/typesGenerated"
98
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
109
import { Stack } from "components/Stack/Stack"
1110
import { TableEmpty } from "components/TableEmpty/TableEmpty"
@@ -16,6 +15,7 @@ import { FC } from "react"
1615
import { AlertBanner } from "components/AlertBanner/AlertBanner"
1716
import IconButton from "@material-ui/core/IconButton/IconButton"
1817
import { useTranslation } from "react-i18next"
18+
import { APIKeyWithOwner } from "api/typesGenerated"
1919

2020
const lastUsedOrNever = (lastUsed: string) => {
2121
const t = dayjs(lastUsed)
@@ -24,7 +24,8 @@ const lastUsedOrNever = (lastUsed: string) => {
2424
}
2525

2626
export interface TokensPageViewProps {
27-
tokens?: APIKey[]
27+
tokens?: APIKeyWithOwner[]
28+
viewAllTokens: boolean
2829
getTokensError?: Error | unknown
2930
isLoading: boolean
3031
hasLoaded: boolean
@@ -36,6 +37,7 @@ export const TokensPageView: FC<
3637
React.PropsWithChildren<TokensPageViewProps>
3738
> = ({
3839
tokens,
40+
viewAllTokens,
3941
getTokensError,
4042
isLoading,
4143
hasLoaded,
@@ -44,6 +46,7 @@ export const TokensPageView: FC<
4446
}) => {
4547
const theme = useTheme()
4648
const { t } = useTranslation("tokensPage")
49+
const colWidth = viewAllTokens ? "20%" : "25%"
4750

4851
return (
4952
<Stack>
@@ -57,10 +60,13 @@ export const TokensPageView: FC<
5760
<Table>
5861
<TableHead>
5962
<TableRow>
60-
<TableCell width="25%">{t("table.id")}</TableCell>
61-
<TableCell width="25%">{t("table.createdAt")}</TableCell>
62-
<TableCell width="25%">{t("table.lastUsed")}</TableCell>
63-
<TableCell width="25%">{t("table.expiresAt")}</TableCell>
63+
<TableCell width={colWidth}>{t("table.id")}</TableCell>
64+
<TableCell width={colWidth}>{t("table.createdAt")}</TableCell>
65+
<TableCell width={colWidth}>{t("table.lastUsed")}</TableCell>
66+
<TableCell width={colWidth}>{t("table.expiresAt")}</TableCell>
67+
{viewAllTokens && (
68+
<TableCell width="20%">{t("table.owner")}</TableCell>
69+
)}
6470
<TableCell width="0%"></TableCell>
6571
</TableRow>
6672
</TableHead>
@@ -102,6 +108,13 @@ export const TokensPageView: FC<
102108
{dayjs(token.expires_at).fromNow()}
103109
</span>
104110
</TableCell>
111+
{viewAllTokens && (
112+
<TableCell>
113+
<span style={{ color: theme.palette.text.secondary }}>
114+
{token.username}
115+
</span>
116+
</TableCell>
117+
)}
105118
<TableCell>
106119
<span style={{ color: theme.palette.text.secondary }}>
107120
<IconButton

0 commit comments

Comments
 (0)