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)
- },
- },
- },
-)