diff --git a/.vscode/settings.json b/.vscode/settings.json index 809770f4f1a13..18adff1b26787 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -113,6 +113,7 @@ "stretchr", "STTY", "stuntest", + "tanstack", "tailbroker", "tailcfg", "tailexchange", diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index d9c3acc14b734..15711ddcccea0 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -7,8 +7,11 @@ Aliases: list, ls Flags: + -a, --all Specifies whether all users' tokens will be listed or not (must have + Owner role to see all tokens). -c, --column strings Columns to display in table output. Available columns: id, last used, - expires at, created at (default [id,last used,expires at,created at]) + expires at, created at, owner (default [id,last used,expires + at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") diff --git a/cli/tokens.go b/cli/tokens.go index 125f5d35c6b1c..427e45606fcc3 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -2,10 +2,13 @@ package cli import ( "fmt" + "os" "strings" "time" + "github.com/google/uuid" "github.com/spf13/cobra" + "golang.org/x/exp/slices" "golang.org/x/xerrors" "github.com/coder/coder/cli/cliflag" @@ -83,12 +86,47 @@ func createToken() *cobra.Command { return cmd } +// tokenListRow is the type provided to the OutputFormatter. +type tokenListRow struct { + // For JSON format: + codersdk.APIKey `table:"-"` + + // For table format: + ID string `json:"-" table:"id,default_sort"` + LastUsed time.Time `json:"-" table:"last used"` + ExpiresAt time.Time `json:"-" table:"expires at"` + CreatedAt time.Time `json:"-" table:"created at"` + Owner string `json:"-" table:"owner"` +} + +func tokenListRowFromToken(token codersdk.APIKey, usersByID map[uuid.UUID]codersdk.User) tokenListRow { + user := usersByID[token.UserID] + + return tokenListRow{ + APIKey: token, + ID: token.ID, + LastUsed: token.LastUsed, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + Owner: user.Username, + } +} + func listTokens() *cobra.Command { - formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]codersdk.APIKey{}, nil), - cliui.JSONFormat(), - ) + // we only display the 'owner' column if the --all argument is passed in + defaultCols := []string{"id", "last used", "expires at", "created at"} + if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") { + defaultCols = append(defaultCols, "owner") + } + var ( + all bool + displayTokens []tokenListRow + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]tokenListRow{}, defaultCols), + cliui.JSONFormat(), + ) + ) cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -99,18 +137,36 @@ func listTokens() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - keys, err := client.Tokens(cmd.Context(), codersdk.Me) + tokens, err := client.Tokens(cmd.Context(), codersdk.Me, codersdk.TokensFilter{ + IncludeAll: all, + }) if err != nil { - return xerrors.Errorf("create tokens: %w", err) + return xerrors.Errorf("list tokens: %w", err) } - if len(keys) == 0 { + if len(tokens) == 0 { cmd.Println(cliui.Styles.Wrap.Render( "No tokens found.", )) } - out, err := formatter.Format(cmd.Context(), keys) + 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) + } + + out, err := formatter.Format(cmd.Context(), displayTokens) if err != nil { return err } @@ -120,6 +176,9 @@ func listTokens() *cobra.Command { }, } + cmd.Flags().BoolVarP(&all, "all", "a", false, + "Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens).") + formatter.AttachFlags(cmd) return cmd } diff --git a/coderd/apikey.go b/coderd/apikey.go index 7c4c6d7650081..ef0921d037e0c 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/http" + "strconv" "time" "github.com/go-chi/chi/v5" @@ -175,15 +176,35 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { // @Success 200 {array} codersdk.APIKey // @Router /users/{user}/keys/tokens [get] func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + keys []database.APIKey + err error + queryStr = r.URL.Query().Get("include_all") + includeAll, _ = strconv.ParseBool(queryStr) + ) - keys, err := api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching API keys.", - Detail: err.Error(), - }) - return + if includeAll { + // get tokens for all users + keys, err = api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API keys.", + Detail: err.Error(), + }) + return + } + } else { + // get user's tokens only + keys, err = api.Database.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: user.ID}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API keys.", + Detail: err.Error(), + }) + return + } } keys, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, keys) diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 2cc0596fb3e13..4d531dace0e6a 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -24,7 +24,7 @@ func TestTokenCRUD(t *testing.T) { defer cancel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - keys, err := client.Tokens(ctx, codersdk.Me) + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.Empty(t, keys) @@ -32,7 +32,7 @@ func TestTokenCRUD(t *testing.T) { require.NoError(t, err) require.Greater(t, len(res.Key), 2) - keys, err = client.Tokens(ctx, codersdk.Me) + keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.EqualValues(t, len(keys), 1) require.Contains(t, res.Key, keys[0].ID) @@ -45,7 +45,7 @@ func TestTokenCRUD(t *testing.T) { err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID) require.NoError(t, err) - keys, err = client.Tokens(ctx, codersdk.Me) + keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.Empty(t, keys) } @@ -64,7 +64,7 @@ func TestTokenScoped(t *testing.T) { require.NoError(t, err) require.Greater(t, len(res.Key), 2) - keys, err := client.Tokens(ctx, codersdk.Me) + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.EqualValues(t, len(keys), 1) require.Contains(t, res.Key, keys[0].ID) @@ -83,7 +83,7 @@ func TestTokenDuration(t *testing.T) { Lifetime: time.Hour * 24 * 7, }) require.NoError(t, err) - keys, err := client.Tokens(ctx, codersdk.Me) + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*6*24)) require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*8*24)) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 2b594bd1ee33a..c4d4fd8a541f0 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -347,7 +347,9 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a }) require.NoError(t, err, "create token") - apiKeys, err := client.Tokens(ctx, admin.UserID.String()) + apiKeys, err := client.Tokens(ctx, admin.UserID.String(), codersdk.TokensFilter{ + IncludeAll: true, + }) require.NoError(t, err, "get tokens") apiKey := apiKeys[0] diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 91e912cb90271..ca2610e52ed63 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -40,6 +40,10 @@ func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database. return fetchWithPostFilter(q.auth, q.db.GetAPIKeysByLoginType)(ctx, loginType) } +func (q *querier) GetAPIKeysByUserID(ctx context.Context, params database.GetAPIKeysByUserIDParams) ([]database.APIKey, error) { + return fetchWithPostFilter(q.auth, q.db.GetAPIKeysByUserID)(ctx, database.GetAPIKeysByUserIDParams{LoginType: params.LoginType, UserID: params.UserID}) +} + func (q *querier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]database.APIKey, error) { return fetchWithPostFilter(q.auth, q.db.GetAPIKeysLastUsedAfter)(ctx, lastUsed) } diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index 8fc6178001f1d..11d03c9be3718 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -31,6 +31,18 @@ func (s *MethodTestSuite) TestAPIKey() { Asserts(a, rbac.ActionRead, b, rbac.ActionRead). Returns(slice.New(a, b)) })) + s.Run("GetAPIKeysByUserID", s.Subtest(func(db database.Store, check *expects) { + idAB := uuid.New() + idC := uuid.New() + + keyA, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: idAB, LoginType: database.LoginTypeToken}) + keyB, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: idAB, LoginType: database.LoginTypeToken}) + _, _ = dbgen.APIKey(s.T(), db, database.APIKey{UserID: idC, LoginType: database.LoginTypeToken}) + + check.Args(database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: idAB}). + Asserts(keyA, rbac.ActionRead, keyB, rbac.ActionRead). + Returns(slice.New(keyA, keyB)) + })) s.Run("GetAPIKeysLastUsedAfter", s.Subtest(func(db database.Store, check *expects) { a, _ := dbgen.APIKey(s.T(), db, database.APIKey{LastUsed: time.Now().Add(time.Hour)}) b, _ := dbgen.APIKey(s.T(), db, database.APIKey{LastUsed: time.Now().Add(time.Hour)}) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 1426eb494833f..45cf402a98ac3 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -484,6 +484,19 @@ func (q *fakeQuerier) GetAPIKeysByLoginType(_ context.Context, t database.LoginT return apiKeys, nil } +func (q *fakeQuerier) GetAPIKeysByUserID(_ context.Context, params database.GetAPIKeysByUserIDParams) ([]database.APIKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + apiKeys := make([]database.APIKey, 0) + for _, key := range q.apiKeys { + if key.UserID == params.UserID && key.LoginType == params.LoginType { + apiKeys = append(apiKeys, key) + } + } + return apiKeys, nil +} + func (q *fakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4f7bf73b4de3e..4c2c9da79ab4a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -31,6 +31,7 @@ type sqlcQuerier interface { DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) + GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveUserCount(ctx context.Context) (int64, error) // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1f1c79bbc624d..354a6ee04c963 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -249,6 +249,50 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT return items, nil } +const getAPIKeysByUserID = `-- name: GetAPIKeysByUserID :many +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 AND user_id = $2 +` + +type GetAPIKeysByUserIDParams struct { + LoginType LoginType `db:"login_type" json:"login_type"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error) { + rows, err := q.db.QueryContext(ctx, getAPIKeysByUserID, arg.LoginType, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []APIKey + for rows.Next() { + var i APIKey + if err := rows.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.LifetimeSeconds, + &i.IPAddress, + &i.Scope, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE last_used > $1 ` diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index c0de70ba2e865..437f7f5a09bfd 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -14,6 +14,9 @@ SELECT * FROM api_keys WHERE last_used > $1; -- name: GetAPIKeysByLoginType :many SELECT * FROM api_keys WHERE login_type = $1; +-- name: GetAPIKeysByUserID :many +SELECT * FROM api_keys WHERE login_type = $1 AND user_id = $2; + -- name: InsertAPIKey :one INSERT INTO api_keys ( diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 399ff41a0db92..d84e3debbf406 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -12,11 +12,11 @@ import ( // APIKey: do not ever return the HashedSecret type APIKey struct { - ID string `json:"id" table:"id,default_sort" validate:"required"` + ID string `json:"id" validate:"required"` UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` - LastUsed time.Time `json:"last_used" table:"last used" validate:"required" format:"date-time"` - ExpiresAt time.Time `json:"expires_at" table:"expires at" validate:"required" format:"date-time"` - CreatedAt time.Time `json:"created_at" table:"created at" validate:"required" format:"date-time"` + LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` + ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` + CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` Scope APIKeyScope `json:"scope" validate:"required" enums:"all,application_connect"` @@ -86,9 +86,23 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyR return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } +type TokensFilter struct { + IncludeAll bool `json:"include_all"` +} + +// asRequestOption returns a function that can be used in (*Client).Request. +// It modifies the request query parameters. +func (f TokensFilter) asRequestOption() RequestOption { + return func(r *http.Request) { + q := r.URL.Query() + q.Set("include_all", fmt.Sprintf("%t", f.IncludeAll)) + r.URL.RawQuery = q.Encode() + } +} + // Tokens list machine API keys. -func (c *Client) Tokens(ctx context.Context, userID string) ([]APIKey, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil) +func (c *Client) Tokens(ctx context.Context, userID string, filter TokensFilter) ([]APIKey, 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 } diff --git a/docs/cli/coder_tokens_list.md b/docs/cli/coder_tokens_list.md index ad12ccb9416a7..f629f4825436f 100644 --- a/docs/cli/coder_tokens_list.md +++ b/docs/cli/coder_tokens_list.md @@ -12,9 +12,17 @@ coder tokens list [flags] ## Flags +### --all, -a + +Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens). +
+| | | +| --- | --- | +| Default | false | + ### --column, -c -Columns to display in table output. Available columns: id, last used, expires at, created at +Columns to display in table output. Available columns: id, last used, expires at, created at, owner
| | | | --- | --- | diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 37d89bf22288f..1c0a6d77b8927 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -130,9 +130,14 @@ export const getApiKey = async (): Promise => { return response.data } -export const getTokens = async (): Promise => { +export const getTokens = async ( + params: TypesGen.TokensFilter, +): Promise => { const response = await axios.get( - "/api/v2/users/me/keys/tokens", + `/api/v2/users/me/keys/tokens`, + { + params, + }, ) return response.data } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c7159fb50a8f8..cf36bd98bff3b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -795,6 +795,11 @@ export interface TemplateVersionsByTemplateRequest extends Pagination { readonly template_id: string } +// From codersdk/apikey.go +export interface TokensFilter { + readonly include_all: boolean +} + // From codersdk/deployment.go export interface TraceConfig { readonly enable: DeploymentConfigField diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index 8450a9050d271..e838753608850 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -19,6 +19,7 @@ import starterTemplatesPage from "./starterTemplatesPage.json" import starterTemplatePage from "./starterTemplatePage.json" import createTemplatePage from "./createTemplatePage.json" import userSettingsPage from "./userSettingsPage.json" +import tokensPage from "./tokensPage.json" export const en = { common, @@ -42,4 +43,5 @@ export const en = { starterTemplatePage, createTemplatePage, userSettingsPage, + tokensPage, } diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json new file mode 100644 index 0000000000000..4012a2cb5c4ae --- /dev/null +++ b/site/src/i18n/en/tokensPage.json @@ -0,0 +1,18 @@ +{ + "title": "Tokens", + "description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ", + "emptyState": "No tokens found", + "deleteToken": { + "delete": "Delete Token", + "deleteCaption": "Are you sure you want to delete this token?", + "deleteSuccess": "Token has been deleted", + "deleteFailure": "Failed to delete token" + }, + "toggleLabel": "Show all tokens", + "table": { + "id": "ID", + "createdAt": "Created At", + "lastUsed": "Last Used", + "expiresAt": "Expires At" + } +} diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index 5e6a6c9c2629d..34612c180a6a9 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -1,66 +1,91 @@ -import { FC, PropsWithChildren } from "react" +import { FC, PropsWithChildren, useState } from "react" import { Section } from "../../../components/SettingsLayout/Section" import { TokensPageView } from "./TokensPageView" -import { tokensMachine } from "xServices/tokens/tokensXService" -import { useMachine } from "@xstate/react" import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" import { Typography } from "components/Typography/Typography" import makeStyles from "@material-ui/core/styles/makeStyles" - -export const Language = { - title: "Tokens", - descriptionPrefix: - "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ", - deleteTitle: "Delete Token", - deleteDescription: "Are you sure you want to delete this token?", -} +import { useTranslation } from "react-i18next" +import { useTokensData, useDeleteToken } from "./hooks" +import { displaySuccess, displayError } from "components/GlobalSnackbar/utils" +import { getErrorMessage } from "api/errors" export const TokensPage: FC> = () => { - const [tokensState, tokensSend] = useMachine(tokensMachine) - const isLoading = tokensState.matches("gettingTokens") - const hasLoaded = tokensState.matches("loaded") - const { getTokensError, tokens, deleteTokenId } = tokensState.context const styles = useStyles() + const { t } = useTranslation("tokensPage") + const [tokenIdToDelete, setTokenIdToDelete] = useState( + undefined, + ) + + const { + data: tokens, + error: getTokensError, + isFetching, + isFetched, + queryKey, + } = useTokensData({ + include_all: true, + }) + + 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 = ( <> - {Language.descriptionPrefix}{" "} + {t("description")}{" "} coder tokens create command. ) const content = ( - {Language.deleteDescription} + {t("deleteToken.deleteCaption")}

- {deleteTokenId} + {tokenIdToDelete}
) return ( <> -
+
{ - tokensSend({ type: "DELETE_TOKEN", id }) + setTokenIdToDelete(id) }} />
{ - tokensSend("CONFIRM_DELETE_TOKEN") + if (!tokenIdToDelete) { + return + } + deleteToken(tokenIdToDelete, { + onError: onDeleteError, + onSuccess: onDeleteSuccess, + }) }} onClose={() => { - tokensSend("CANCEL_DELETE_TOKEN") + setTokenIdToDelete(undefined) }} /> @@ -75,6 +100,10 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.text.primary, borderRadius: 2, }, + formRow: { + justifyContent: "end", + marginBottom: "10px", + }, })) export default TokensPage diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx index 4b519a6b04695..09d44fd297ddd 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx @@ -15,15 +15,7 @@ import dayjs from "dayjs" import { FC } from "react" import { AlertBanner } from "components/AlertBanner/AlertBanner" import IconButton from "@material-ui/core/IconButton/IconButton" - -export const Language = { - idLabel: "ID", - createdAtLabel: "Created At", - lastUsedLabel: "Last Used", - expiresAtLabel: "Expires At", - emptyMessage: "No tokens found", - ariaDeleteLabel: "Delete Token", -} +import { useTranslation } from "react-i18next" const lastUsedOrNever = (lastUsed: string) => { const t = dayjs(lastUsed) @@ -51,6 +43,7 @@ export const TokensPageView: FC< deleteTokenError, }) => { const theme = useTheme() + const { t } = useTranslation("tokensPage") return ( @@ -64,10 +57,10 @@ export const TokensPageView: FC< - {Language.idLabel} - {Language.createdAtLabel} - {Language.lastUsedLabel} - {Language.expiresAtLabel} + {t("table.id")} + {t("table.createdAt")} + {t("table.lastUsed")} + {t("table.expiresAt")} @@ -77,7 +70,7 @@ export const TokensPageView: FC< - + {tokens?.map((token) => { @@ -116,7 +109,7 @@ export const TokensPageView: FC< onDelete(token.id) }} size="medium" - aria-label={Language.ariaDeleteLabel} + aria-label={t("deleteToken.delete")} > diff --git a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts new file mode 100644 index 0000000000000..a739d95bf98b4 --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts @@ -0,0 +1,36 @@ +import { + useQuery, + useMutation, + useQueryClient, + QueryKey, +} from "@tanstack/react-query" +import { getTokens, deleteAPIKey } from "api/api" +import { TokensFilter } from "api/typesGenerated" + +export const useTokensData = ({ include_all }: TokensFilter) => { + const queryKey = ["tokens", include_all] + const result = useQuery({ + queryKey, + queryFn: () => + getTokens({ + include_all, + }), + }) + + return { + queryKey, + ...result, + } +} + +export const useDeleteToken = (queryKey: QueryKey) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteAPIKey, + onSuccess: () => { + // Invalidate and refetch + void queryClient.invalidateQueries(queryKey) + }, + }) +} diff --git a/site/src/xServices/tokens/tokensXService.ts b/site/src/xServices/tokens/tokensXService.ts deleted file mode 100644 index 1bea3af5b9c94..0000000000000 --- a/site/src/xServices/tokens/tokensXService.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { getTokens, deleteAPIKey } from "api/api" -import { APIKey } from "api/typesGenerated" -import { displaySuccess } from "components/GlobalSnackbar/utils" -import { createMachine, assign } from "xstate" - -interface Context { - tokens?: APIKey[] - getTokensError?: unknown - deleteTokenError?: unknown - deleteTokenId?: string -} - -type Events = - | { type: "DELETE_TOKEN"; id: string } - | { type: "CONFIRM_DELETE_TOKEN" } - | { type: "CANCEL_DELETE_TOKEN" } - -const Language = { - deleteSuccess: "Token has been deleted", -} - -export const tokensMachine = createMachine( - { - id: "tokensState", - predictableActionArguments: true, - schema: { - context: {} as Context, - events: {} as Events, - services: {} as { - getTokens: { - data: APIKey[] - } - deleteToken: { - data: unknown - } - }, - }, - tsTypes: {} as import("./tokensXService.typegen").Typegen0, - initial: "gettingTokens", - states: { - gettingTokens: { - entry: "clearGetTokensError", - invoke: { - src: "getTokens", - onDone: [ - { - actions: "assignTokens", - target: "loaded", - }, - ], - onError: [ - { - actions: "assignGetTokensError", - target: "notLoaded", - }, - ], - }, - }, - notLoaded: { - type: "final", - }, - loaded: { - on: { - DELETE_TOKEN: { - actions: "assignDeleteTokenId", - target: "confirmTokenDelete", - }, - }, - }, - confirmTokenDelete: { - on: { - CANCEL_DELETE_TOKEN: { - actions: "clearDeleteTokenId", - target: "loaded", - }, - CONFIRM_DELETE_TOKEN: { - target: "deletingToken", - }, - }, - }, - deletingToken: { - entry: "clearDeleteTokenError", - invoke: { - src: "deleteToken", - onDone: [ - { - actions: ["clearDeleteTokenId", "notifySuccessTokenDeleted"], - target: "gettingTokens", - }, - ], - onError: [ - { - actions: ["clearDeleteTokenId", "assignDeleteTokenError"], - target: "loaded", - }, - ], - }, - }, - }, - }, - { - services: { - getTokens: () => getTokens(), - deleteToken: (context) => { - if (context.deleteTokenId === undefined) { - return Promise.reject("No token id to delete") - } - - return deleteAPIKey(context.deleteTokenId) - }, - }, - actions: { - assignTokens: assign({ - tokens: (_, { data }) => data, - }), - assignGetTokensError: assign({ - getTokensError: (_, { data }) => data, - }), - clearGetTokensError: assign({ - getTokensError: (_) => undefined, - }), - assignDeleteTokenId: assign({ - deleteTokenId: (_, event) => event.id, - }), - clearDeleteTokenId: assign({ - deleteTokenId: (_) => undefined, - }), - assignDeleteTokenError: assign({ - deleteTokenError: (_, { data }) => data, - }), - clearDeleteTokenError: assign({ - deleteTokenError: (_) => undefined, - }), - notifySuccessTokenDeleted: () => { - displaySuccess(Language.deleteSuccess) - }, - }, - }, -)