diff --git a/.vscode/settings.json b/.vscode/settings.json index 18adff1b26787..d4c34f3a9a106 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "agentsdk", "apps", "ASKPASS", + "authcheck", "autostop", "awsidentity", "bodyclose", diff --git a/cli/tokens.go b/cli/tokens.go index 427e45606fcc3..082d83303824a 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -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) diff --git a/coderd/apikey.go b/coderd/apikey.go index ef0921d037e0c..1bdafe9007d9f 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -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) diff --git a/codersdk/apikey.go b/codersdk/apikey.go index d84e3debbf406..ba24211169072 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -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) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ac99f7b3eb2b5..03438161120f5 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -142,8 +142,8 @@ export const getApiKey = async (): Promise => { export const getTokens = async ( params: TypesGen.TokensFilter, -): Promise => { - const response = await axios.get( +): Promise => { + const response = await axios.get( `/api/v2/users/me/keys/tokens`, { params, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index dfcb01a490bcb..cc2d5f95feab3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json index 4012a2cb5c4ae..a6b4cc3f94d7e 100644 --- a/site/src/i18n/en/tokensPage.json +++ b/site/src/i18n/en/tokensPage.json @@ -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}} 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?

<4>{{tokenId}}", "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" } } diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index 34612c180a6a9..b7af557668d7a 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -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> = () => { const styles = useStyles() const { t } = useTranslation("tokensPage") + + const cliCreateCommand = "coder tokens create" + const description = ( + + Tokens are used to authenticate with the Coder API. You can create a token + with the Coder CLI using the {{ cliCreateCommand }} command. + + ) + const [tokenIdToDelete, setTokenIdToDelete] = useState( undefined, ) + const [viewAllTokens, setViewAllTokens] = useState(false) + const { data: perms } = useCheckTokenPermissions() const { data: tokens, @@ -23,44 +31,25 @@ export const TokensPage: FC> = () => { 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")}{" "} - coder tokens create command. - - ) - - const content = ( - - {t("deleteToken.deleteCaption")} -
-
- {tokenIdToDelete} -
- ) - return ( <> -
+
+ > = () => { }} />
- - { - if (!tokenIdToDelete) { - return - } - deleteToken(tokenIdToDelete, { - onError: onDeleteError, - onSuccess: onDeleteSuccess, - }) - }} - onClose={() => { - setTokenIdToDelete(undefined) - }} + ) } 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, + }, }, })) diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx index 09d44fd297ddd..547fecc1133d1 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx @@ -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 > = ({ 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 ( @@ -57,10 +60,13 @@ export const TokensPageView: FC< - {t("table.id")} - {t("table.createdAt")} - {t("table.lastUsed")} - {t("table.expiresAt")} + {t("table.id")} + {t("table.createdAt")} + {t("table.lastUsed")} + {t("table.expiresAt")} + {viewAllTokens && ( + {t("table.owner")} + )} @@ -102,6 +108,13 @@ export const TokensPageView: FC< {dayjs(token.expires_at).fromNow()} + {viewAllTokens && ( + + + {token.username} + + + )} void +}> = ({ queryKey, tokenId, setTokenId }) => { + const { t } = useTranslation("tokensPage") + + const description = ( + + Are you sure you want to delete this token? +
+
+ {{ tokenId }} +
+ ) + + 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 ( + { + if (!tokenId) { + return + } + deleteToken(tokenId, { + onError: onDeleteError, + onSuccess: onDeleteSuccess, + }) + }} + onClose={() => { + setTokenId(undefined) + }} + /> + ) +} diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx b/site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx new file mode 100644 index 0000000000000..516559f05b597 --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/components/TokensSwitch.tsx @@ -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 ( + + {hasReadAll && ( + setViewAllTokens(!viewAllTokens)} + name="viewAllTokens" + color="primary" + /> + } + label={t("toggleLabel")} + /> + )} + + ) +} + +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", + }, + }, +})) diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/index.ts b/site/src/pages/UserSettingsPage/TokensPage/components/index.ts new file mode 100644 index 0000000000000..e58a46323c61a --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/components/index.ts @@ -0,0 +1,2 @@ +export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog" +export { TokensSwitch } from "./TokensSwitch" diff --git a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts index a739d95bf98b4..ec69a8a59ca8c 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts +++ b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts @@ -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()