From 321252bbc3613cb095aa5d4ba038ff3b8dc0481c Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Wed, 15 Feb 2023 17:14:20 +0000 Subject: [PATCH 01/12] added query for tokens by user id --- coderd/database/dbauthz/querier.go | 4 +++ coderd/database/dbauthz/querier_test.go | 12 ++++++++ coderd/database/dbfake/databasefake.go | 13 +++++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 39 +++++++++++++++++++++++++ coderd/database/queries/apikeys.sql | 3 ++ 6 files changed, 72 insertions(+) diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index 3f32b3360a04d..a3f8b222b4b84 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) GetTokensByUserID(ctx context.Context, userID uuid.UUID) ([]database.APIKey, error) { + return fetchWithPostFilter(q.auth, q.db.GetTokensByUserID)(ctx, 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 a22b70ac0861f..f4566c32fc1b5 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("GetTokensByUserID", s.Subtest(func(db database.Store, check *expects) { + idA := uuid.New() + idC := uuid.New() + + keyA, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: idA}) + keyB, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: idA}) + _, _ = dbgen.APIKey(s.T(), db, database.APIKey{UserID: idC}) + + check.Args(idA). + 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 f38edca9bc5fb..85d53c84077bd 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -483,6 +483,19 @@ func (q *fakeQuerier) GetAPIKeysByLoginType(_ context.Context, t database.LoginT return apiKeys, nil } +func (q *fakeQuerier) GetTokensByUserID(_ context.Context, userID uuid.UUID) ([]database.APIKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + apiKeys := make([]database.APIKey, 0) + for _, key := range q.apiKeys { + if key.UserID == userID { + 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 d11ad35999c3c..56dd4903b0594 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -92,6 +92,7 @@ type sqlcQuerier interface { GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) GetTemplates(ctx context.Context) ([]Template, error) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) + GetTokensByUserID(ctx context.Context, userID uuid.UUID) ([]APIKey, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a41ae0b363f28..24fb23f0d17f1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -288,6 +288,45 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. return items, nil } +const getTokensByUserID = `-- name: GetTokensByUserID :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='token' AND user_id = $1 +` + +func (q *sqlQuerier) GetTokensByUserID(ctx context.Context, userID uuid.UUID) ([]APIKey, error) { + rows, err := q.db.QueryContext(ctx, getTokensByUserID, 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 insertAPIKey = `-- name: InsertAPIKey :one INSERT INTO api_keys ( diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index c0de70ba2e865..7bd0dab40f29c 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: GetTokensByUserID :many +SELECT * FROM api_keys WHERE login_type='token' AND user_id = $1; + -- name: InsertAPIKey :one INSERT INTO api_keys ( From 5971367bfad1d8039a045eaae00a638c2eec4bff Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Wed, 15 Feb 2023 21:29:26 +0000 Subject: [PATCH 02/12] updated query args --- cli/testdata/coder_tokens_list_--help.golden | 1 + cli/tokens.go | 18 +++++++--- coderd/apikey.go | 38 ++++++++++++++++---- coderd/apikey_test.go | 10 +++--- coderd/coderdtest/authorize.go | 4 ++- coderd/database/dbauthz/querier.go | 4 +-- coderd/database/dbauthz/querier_test.go | 12 +++---- coderd/database/dbfake/databasefake.go | 4 +-- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 21 ++++++----- coderd/database/queries/apikeys.sql | 4 +-- codersdk/apikey.go | 8 +++-- site/src/api/typesGenerated.ts | 5 +++ 13 files changed, 90 insertions(+), 41 deletions(-) diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index d9c3acc14b734..7e0b373ca8089 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -11,6 +11,7 @@ Flags: expires at, created at (default [id,last used,expires at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") + -a, --all Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens) Global Flags: --global-config coder Path to the global coder config directory. diff --git a/cli/tokens.go b/cli/tokens.go index dadbce5b3c02f..5f292ab10abec 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -86,9 +86,12 @@ func createToken() *cobra.Command { } func listTokens() *cobra.Command { - formatter := cliui.NewOutputFormatter( - cliui.TableFormat([]codersdk.APIKey{}, nil), - cliui.JSONFormat(), + var ( + all bool + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.APIKey{}, nil), + cliui.JSONFormat(), + ) ) cmd := &cobra.Command{ @@ -101,9 +104,11 @@ func listTokens() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - keys, err := client.Tokens(cmd.Context(), codersdk.Me) + keys, err := client.Tokens(cmd.Context(), codersdk.Me, codersdk.GetTokensRequest{ + All: all, + }) if err != nil { - return xerrors.Errorf("create tokens: %w", err) + return xerrors.Errorf("list tokens: %w", err) } if len(keys) == 0 { @@ -122,6 +127,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 06ad18f06c232..8bf862edd66a1 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -178,18 +178,42 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { // @Router /users/{user}/keys/tokens [get] func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() + ctx = r.Context() + user = httpmw.UserParam(r) ) - 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(), - }) + var getTokens codersdk.GetTokensRequest + if !httpapi.Read(ctx, rw, r, &getTokens) { return } + var ( + keys []database.APIKey + err error + ) + + if !getTokens.All { + // 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 + } + } else { + // get the 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 + } + } + keys, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, keys) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 2cc0596fb3e13..c4208145c1f62 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.GetTokensRequest{}) 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.GetTokensRequest{}) 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.GetTokensRequest{}) 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.GetTokensRequest{}) 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.GetTokensRequest{}) 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 294ac80c08859..1fdc874358070 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.GetTokensRequest{ + All: 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 a3f8b222b4b84..d237c783305dd 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -40,8 +40,8 @@ func (q *querier) GetAPIKeysByLoginType(ctx context.Context, loginType database. return fetchWithPostFilter(q.auth, q.db.GetAPIKeysByLoginType)(ctx, loginType) } -func (q *querier) GetTokensByUserID(ctx context.Context, userID uuid.UUID) ([]database.APIKey, error) { - return fetchWithPostFilter(q.auth, q.db.GetTokensByUserID)(ctx, userID) +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) { diff --git a/coderd/database/dbauthz/querier_test.go b/coderd/database/dbauthz/querier_test.go index f4566c32fc1b5..227f6eddaa697 100644 --- a/coderd/database/dbauthz/querier_test.go +++ b/coderd/database/dbauthz/querier_test.go @@ -31,15 +31,15 @@ func (s *MethodTestSuite) TestAPIKey() { Asserts(a, rbac.ActionRead, b, rbac.ActionRead). Returns(slice.New(a, b)) })) - s.Run("GetTokensByUserID", s.Subtest(func(db database.Store, check *expects) { - idA := uuid.New() + 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: idA}) - keyB, _ := dbgen.APIKey(s.T(), db, database.APIKey{UserID: idA}) - _, _ = dbgen.APIKey(s.T(), db, database.APIKey{UserID: idC}) + 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(idA). + check.Args(database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: idAB}). Asserts(keyA, rbac.ActionRead, keyB, rbac.ActionRead). Returns(slice.New(keyA, keyB)) })) diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 85d53c84077bd..2230e17b0d547 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -483,13 +483,13 @@ func (q *fakeQuerier) GetAPIKeysByLoginType(_ context.Context, t database.LoginT return apiKeys, nil } -func (q *fakeQuerier) GetTokensByUserID(_ context.Context, userID uuid.UUID) ([]database.APIKey, error) { +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 == userID { + if key.UserID == params.UserID && key.LoginType == params.LoginType { apiKeys = append(apiKeys, key) } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 56dd4903b0594..07b694a7c10b0 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 @@ -92,7 +93,6 @@ type sqlcQuerier interface { GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) GetTemplates(ctx context.Context) ([]Template, error) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) - GetTokensByUserID(ctx context.Context, userID uuid.UUID) ([]APIKey, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 24fb23f0d17f1..b45bc0ab38901 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -249,12 +249,17 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT 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 +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 ` -func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { - rows, err := q.db.QueryContext(ctx, getAPIKeysLastUsedAfter, lastUsed) +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 } @@ -288,12 +293,12 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. return items, nil } -const getTokensByUserID = `-- name: GetTokensByUserID :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='token' AND user_id = $1 +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 ` -func (q *sqlQuerier) GetTokensByUserID(ctx context.Context, userID uuid.UUID) ([]APIKey, error) { - rows, err := q.db.QueryContext(ctx, getTokensByUserID, userID) +func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { + rows, err := q.db.QueryContext(ctx, getAPIKeysLastUsedAfter, lastUsed) if err != nil { return nil, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 7bd0dab40f29c..437f7f5a09bfd 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -14,8 +14,8 @@ SELECT * FROM api_keys WHERE last_used > $1; -- name: GetAPIKeysByLoginType :many SELECT * FROM api_keys WHERE login_type = $1; --- name: GetTokensByUserID :many -SELECT * FROM api_keys WHERE login_type='token' AND user_id = $1; +-- name: GetAPIKeysByUserID :many +SELECT * FROM api_keys WHERE login_type = $1 AND user_id = $2; -- name: InsertAPIKey :one INSERT INTO diff --git a/codersdk/apikey.go b/codersdk/apikey.go index c4611576ed010..a7ec6fa8641a4 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -86,9 +86,13 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyR return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } +type GetTokensRequest struct { + All bool `json:"all"` +} + // 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, req GetTokensRequest) ([]APIKey, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), req) if err != nil { return nil, err } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bf79a6122e9c8..67d505d51ed47 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -379,6 +379,11 @@ export interface GenerateAPIKeyResponse { readonly key: string } +// From codersdk/apikey.go +export interface GetTokensRequest { + readonly all: boolean +} + // From codersdk/users.go export interface GetUsersResponse { readonly users: User[] From b881ff9f49c37bdce8e76197e563cc0ea140e511 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Wed, 15 Feb 2023 21:47:04 +0000 Subject: [PATCH 03/12] adding owner col --- cli/testdata/coder_tokens_list_--help.golden | 2 +- coderd/apikey.go | 3 --- codersdk/apikey.go | 2 +- docs/cli/coder_tokens_list.md | 3 ++- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 7e0b373ca8089..56326c544a19a 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -8,7 +8,7 @@ Aliases: Flags: -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") -a, --all Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens) diff --git a/coderd/apikey.go b/coderd/apikey.go index 8bf862edd66a1..57b7fefad1533 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -183,9 +183,6 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { ) var getTokens codersdk.GetTokensRequest - if !httpapi.Read(ctx, rw, r, &getTokens) { - return - } var ( keys []database.APIKey diff --git a/codersdk/apikey.go b/codersdk/apikey.go index a7ec6fa8641a4..09d19c04aab5b 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -13,7 +13,7 @@ import ( // APIKey: do not ever return the HashedSecret type APIKey struct { ID string `json:"id" table:"id,default_sort" validate:"required"` - UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` + UserID uuid.UUID `json:"user_id" table:"owner" 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"` diff --git a/docs/cli/coder_tokens_list.md b/docs/cli/coder_tokens_list.md index af7e034990dd1..efe3b38920cbd 100644 --- a/docs/cli/coder_tokens_list.md +++ b/docs/cli/coder_tokens_list.md @@ -9,7 +9,8 @@ coder tokens list [flags] ### Options ``` - -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]) + -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, owner, last used, expires at, created at (default [id,owner,last used,expires at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") ``` From 2085d14a50c14b4df206eba0852adfc08170be48 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 16 Feb 2023 21:10:44 +0000 Subject: [PATCH 04/12] fix request params --- cli/tokens.go | 4 ++-- coderd/apikey.go | 26 ++++++++++----------- coderd/apikey_test.go | 10 ++++---- coderd/coderdtest/authorize.go | 4 ++-- codersdk/apikey.go | 18 ++++++++++---- site/src/api/api.ts | 7 ++++-- site/src/api/typesGenerated.ts | 10 ++++---- site/src/xServices/tokens/tokensXService.ts | 4 +++- 8 files changed, 48 insertions(+), 35 deletions(-) diff --git a/cli/tokens.go b/cli/tokens.go index 5f292ab10abec..3c0ba4311f8ca 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -104,8 +104,8 @@ func listTokens() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - keys, err := client.Tokens(cmd.Context(), codersdk.Me, codersdk.GetTokensRequest{ - All: all, + keys, err := client.Tokens(cmd.Context(), codersdk.Me, codersdk.TokensFilter{ + IncludeAll: all, }) if err != nil { return xerrors.Errorf("list tokens: %w", err) diff --git a/coderd/apikey.go b/coderd/apikey.go index 57b7fefad1533..974068c0fda5f 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" @@ -178,20 +179,17 @@ func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { // @Router /users/{user}/keys/tokens [get] func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() - user = httpmw.UserParam(r) + ctx = r.Context() + user = httpmw.UserParam(r) + keys []database.APIKey + err error + queryStr = r.URL.Query().Get("include_all") + includeAll, _ = strconv.ParseBool(queryStr) ) - var getTokens codersdk.GetTokensRequest - - var ( - keys []database.APIKey - err error - ) - - if !getTokens.All { - // get user's tokens only - keys, err = api.Database.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{LoginType: database.LoginTypeToken, UserID: user.ID}) + 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.", @@ -200,8 +198,8 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { return } } else { - // get the tokens for all users - keys, err = api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken) + // 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.", diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index c4208145c1f62..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, codersdk.GetTokensRequest{}) + 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, codersdk.GetTokensRequest{}) + 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, codersdk.GetTokensRequest{}) + 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, codersdk.GetTokensRequest{}) + 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, codersdk.GetTokensRequest{}) + 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 1fdc874358070..280ffc0531566 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -347,8 +347,8 @@ 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(), codersdk.GetTokensRequest{ - All: true, + apiKeys, err := client.Tokens(ctx, admin.UserID.String(), codersdk.TokensFilter{ + IncludeAll: true, }) require.NoError(t, err, "get tokens") apiKey := apiKeys[0] diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 09d19c04aab5b..b542987503d1d 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -86,13 +86,23 @@ func (c *Client) CreateAPIKey(ctx context.Context, user string) (GenerateAPIKeyR return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) } -type GetTokensRequest struct { - All bool `json:"all"` +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, req GetTokensRequest) ([]APIKey, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), req) +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/site/src/api/api.ts b/site/src/api/api.ts index 37d89bf22288f..3d2a8665c197f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -130,9 +130,12 @@ 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 67d505d51ed47..6d0eb8802f3c2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -379,11 +379,6 @@ export interface GenerateAPIKeyResponse { readonly key: string } -// From codersdk/apikey.go -export interface GetTokensRequest { - readonly all: boolean -} - // From codersdk/users.go export interface GetUsersResponse { readonly users: User[] @@ -787,6 +782,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/xServices/tokens/tokensXService.ts b/site/src/xServices/tokens/tokensXService.ts index 1bea3af5b9c94..733e0d9f836dd 100644 --- a/site/src/xServices/tokens/tokensXService.ts +++ b/site/src/xServices/tokens/tokensXService.ts @@ -100,7 +100,9 @@ export const tokensMachine = createMachine( }, { services: { - getTokens: () => getTokens(), + getTokens: () => getTokens({ + include_all: false, + }), deleteToken: (context) => { if (context.deleteTokenId === undefined) { return Promise.reject("No token id to delete") From b3c926b55cc430d05142217b21af184e9e0a6d75 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 16 Feb 2023 21:17:29 +0000 Subject: [PATCH 05/12] update-golden-files --- cli/testdata/coder_tokens_list_--help.golden | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 56326c544a19a..42ce8763ee432 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -7,11 +7,13 @@ Aliases: list, ls Flags: - -c, --column strings Columns to display in table output. Available columns: id, last used, - expires at, created at, owner (default [id,last used,expires at,created at]) + -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, owner, + last used, expires at, created at (default [id,owner,last + used,expires at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") - -a, --all Specifies whether all users' tokens will be listed or not (must have Owner role to see all tokens) Global Flags: --global-config coder Path to the global coder config directory. From 3e0757fed3fd0283e5eb3029f44a41ba42fc82fa Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 16 Feb 2023 22:33:10 +0000 Subject: [PATCH 06/12] added owners col to ls table output --- cli/testdata/coder_tokens_list_--help.golden | 6 +-- cli/tokens.go | 57 +++++++++++++++++--- codersdk/apikey.go | 10 ++-- docs/cli/coder_tokens_list.md | 2 +- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 42ce8763ee432..9e54adbf73fa9 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -9,9 +9,9 @@ Aliases: 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, owner, - last used, expires at, created at (default [id,owner,last - used,expires at,created at]) + -c, --column strings Columns to display in table output. Available columns: id, last used, + expires at, created at, owner (default [id,last used,expires + at,created at,owner]) -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 3c0ba4311f8ca..8eb1ee3e212c9 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -85,15 +86,41 @@ 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 { var ( - all bool - formatter = cliui.NewOutputFormatter( - cliui.TableFormat([]codersdk.APIKey{}, nil), + all bool + displayTokens []tokenListRow + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]tokenListRow{}, nil), cliui.JSONFormat(), ) ) - cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -104,20 +131,36 @@ func listTokens() *cobra.Command { return xerrors.Errorf("create codersdk client: %w", err) } - keys, err := client.Tokens(cmd.Context(), codersdk.Me, codersdk.TokensFilter{ + tokens, err := client.Tokens(cmd.Context(), codersdk.Me, codersdk.TokensFilter{ IncludeAll: all, }) if err != nil { 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 } diff --git a/codersdk/apikey.go b/codersdk/apikey.go index b542987503d1d..be0d47e9ea776 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"` - UserID uuid.UUID `json:"user_id" table:"owner" 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"` + ID string `json:"id" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` + 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"` diff --git a/docs/cli/coder_tokens_list.md b/docs/cli/coder_tokens_list.md index efe3b38920cbd..7c3071c691d57 100644 --- a/docs/cli/coder_tokens_list.md +++ b/docs/cli/coder_tokens_list.md @@ -10,7 +10,7 @@ coder tokens list [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, owner, last used, expires at, created at (default [id,owner,last used,expires at,created at]) + -c, --column strings Columns to display in table output. Available columns: id, last used, expires at, created at, owner (default [id,last used,expires at,created at,owner]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") ``` From c8c4821b31b448d23ff19ab742f7a3de4819a2e3 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 17 Feb 2023 17:39:16 +0000 Subject: [PATCH 07/12] added ttoken translations --- site/src/i18n/en/index.ts | 2 ++ site/src/i18n/en/tokensPage.json | 10 ++++++++ .../TokensPage/TokensPageView.tsx | 23 +++++++------------ 3 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 site/src/i18n/en/tokensPage.json 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..e92270757d78c --- /dev/null +++ b/site/src/i18n/en/tokensPage.json @@ -0,0 +1,10 @@ +{ + "emptyState": "No tokens found", + "deleteToken": "Delete Token", + "table": { + "id": "ID", + "createdAt": "Created At", + "lastUsed": "Last Used", + "expiresAt": "Expires At" + } +} diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx index 4b519a6b04695..05b16a3130692 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")} > From 1407c87bb7d59a7546dc256065f9094a230ce3f7 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 17 Feb 2023 17:40:07 +0000 Subject: [PATCH 08/12] prettier --- site/src/api/api.ts | 6 ++++-- site/src/xServices/tokens/tokensXService.ts | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3d2a8665c197f..1c0a6d77b8927 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -130,12 +130,14 @@ export const getApiKey = async (): Promise => { return response.data } -export const getTokens = async (params: TypesGen.TokensFilter): Promise => { +export const getTokens = async ( + params: TypesGen.TokensFilter, +): Promise => { const response = await axios.get( `/api/v2/users/me/keys/tokens`, { params, - } + }, ) return response.data } diff --git a/site/src/xServices/tokens/tokensXService.ts b/site/src/xServices/tokens/tokensXService.ts index 733e0d9f836dd..3459c2c0d2365 100644 --- a/site/src/xServices/tokens/tokensXService.ts +++ b/site/src/xServices/tokens/tokensXService.ts @@ -100,9 +100,10 @@ export const tokensMachine = createMachine( }, { services: { - getTokens: () => getTokens({ - include_all: false, - }), + getTokens: () => + getTokens({ + include_all: false, + }), deleteToken: (context) => { if (context.deleteTokenId === undefined) { return Promise.reject("No token id to delete") From 43c5301dd2271869bb6c6fdc855d13a5c8291a94 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 20 Feb 2023 15:16:22 +0000 Subject: [PATCH 09/12] format table according to arg --- cli/tokens.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/tokens.go b/cli/tokens.go index 8eb1ee3e212c9..ab2d84a45bd9b 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "strings" "time" @@ -113,11 +114,17 @@ func tokenListRowFromToken(token codersdk.APIKey, usersByID map[uuid.UUID]coders } func listTokens() *cobra.Command { + // we only display the 'owner' column if the --all argument is passed in + defaultCols := []string{"id", "last used", "expires at", "created at"} + if len(os.Args) > 5 { + defaultCols = append(defaultCols, "owner") + } + var ( all bool displayTokens []tokenListRow formatter = cliui.NewOutputFormatter( - cliui.TableFormat([]tokenListRow{}, nil), + cliui.TableFormat([]tokenListRow{}, defaultCols), cliui.JSONFormat(), ) ) From b95428d9752595b492f49fccd588e723d5872ade Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 20 Feb 2023 15:27:16 +0000 Subject: [PATCH 10/12] using slice.Contains --- cli/tokens.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/tokens.go b/cli/tokens.go index f952beb8bd85c..427e45606fcc3 100644 --- a/cli/tokens.go +++ b/cli/tokens.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/spf13/cobra" + "golang.org/x/exp/slices" "golang.org/x/xerrors" "github.com/coder/coder/cli/cliflag" @@ -114,7 +115,7 @@ func tokenListRowFromToken(token codersdk.APIKey, usersByID map[uuid.UUID]coders func listTokens() *cobra.Command { // we only display the 'owner' column if the --all argument is passed in defaultCols := []string{"id", "last used", "expires at", "created at"} - if len(os.Args) > 5 { + if slices.Contains(os.Args, "-a") || slices.Contains(os.Args, "--all") { defaultCols = append(defaultCols, "owner") } From 0e81ac95f380cc946718a88979647e7bd2e74cbf Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 21 Feb 2023 15:34:47 +0000 Subject: [PATCH 11/12] refactored token state --- .vscode/settings.json | 1 + site/src/app.tsx | 1 + site/src/i18n/en/tokensPage.json | 10 +- .../TokensPage/TokensPage.tsx | 109 ++++++++++---- .../TokensPage/TokensPageView.tsx | 2 +- .../UserSettingsPage/TokensPage/hooks.ts | 36 +++++ site/src/xServices/tokens/tokensXService.ts | 142 ------------------ 7 files changed, 130 insertions(+), 171 deletions(-) create mode 100644 site/src/pages/UserSettingsPage/TokensPage/hooks.ts delete mode 100644 site/src/xServices/tokens/tokensXService.ts 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/site/src/app.tsx b/site/src/app.tsx index 4b47895aa4bff..cd055cc7567ca 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -15,6 +15,7 @@ const queryClient = new QueryClient({ queries: { retry: false, cacheTime: 0, + refetchOnWindowFocus: false, }, }, }) diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json index e92270757d78c..4012a2cb5c4ae 100644 --- a/site/src/i18n/en/tokensPage.json +++ b/site/src/i18n/en/tokensPage.json @@ -1,6 +1,14 @@ { + "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 Token", + "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", diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index 5e6a6c9c2629d..d1d9552969eff 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -1,66 +1,117 @@ -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 Switch from "@material-ui/core/Switch" +import FormGroup from "@material-ui/core/FormGroup" +import FormControlLabel from "@material-ui/core/FormControlLabel" +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) + } + + console.log("data", tokens) + const description = ( <> - {Language.descriptionPrefix}{" "} + {t("description")}{" "} coder tokens create command. ) const content = ( - {Language.deleteDescription} + {t("deleteToken.deleteCaption")}

- {deleteTokenId} + {tokenIdToDelete}
) + const [state, setState] = useState({ + checkedA: true, + checkedB: true, + }) + + const handleChange = (event) => { + setState({ ...state, [event.target.name]: event.target.checked }) + } return ( <> -
+
+ + + } + label={t("toggleLabel")} + /> + { - 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 +126,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 05b16a3130692..09d44fd297ddd 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx @@ -109,7 +109,7 @@ export const TokensPageView: FC< onDelete(token.id) }} size="medium" - aria-label={t("deleteToken")} + 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 3459c2c0d2365..0000000000000 --- a/site/src/xServices/tokens/tokensXService.ts +++ /dev/null @@ -1,142 +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({ - include_all: false, - }), - 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) - }, - }, - }, -) From c22b8a7954278d0e2128b38f517d0d64c2b2656b Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 21 Feb 2023 17:18:39 +0000 Subject: [PATCH 12/12] cleanup --- cli/testdata/coder_tokens_list_--help.golden | 2 +- docs/cli/coder_tokens_list.md | 2 +- .../TokensPage/TokensPage.tsx | 26 ------------------- site/yarn.lock | 6 ++--- 4 files changed, 5 insertions(+), 31 deletions(-) diff --git a/cli/testdata/coder_tokens_list_--help.golden b/cli/testdata/coder_tokens_list_--help.golden index 9e54adbf73fa9..15711ddcccea0 100644 --- a/cli/testdata/coder_tokens_list_--help.golden +++ b/cli/testdata/coder_tokens_list_--help.golden @@ -11,7 +11,7 @@ Flags: Owner role to see all tokens). -c, --column strings Columns to display in table output. Available columns: id, last used, expires at, created at, owner (default [id,last used,expires - at,created at,owner]) + at,created at]) -h, --help help for list -o, --output string Output format. Available formats: table, json (default "table") diff --git a/docs/cli/coder_tokens_list.md b/docs/cli/coder_tokens_list.md index 7c3071c691d57..16a4e0984e744 100644 --- a/docs/cli/coder_tokens_list.md +++ b/docs/cli/coder_tokens_list.md @@ -10,7 +10,7 @@ coder tokens list [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, owner (default [id,last used,expires at,created at,owner]) + -c, --column strings Columns to display in table output. Available columns: id, last used, 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/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index d1d9552969eff..34612c180a6a9 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -4,9 +4,6 @@ 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 Switch from "@material-ui/core/Switch" -import FormGroup from "@material-ui/core/FormGroup" -import FormControlLabel from "@material-ui/core/FormControlLabel" import { useTranslation } from "react-i18next" import { useTokensData, useDeleteToken } from "./hooks" import { displaySuccess, displayError } from "components/GlobalSnackbar/utils" @@ -43,8 +40,6 @@ export const TokensPage: FC> = () => { setTokenIdToDelete(undefined) } - console.log("data", tokens) - const description = ( <> {t("description")}{" "} @@ -60,31 +55,10 @@ export const TokensPage: FC> = () => { {tokenIdToDelete} ) - const [state, setState] = useState({ - checkedA: true, - checkedB: true, - }) - - const handleChange = (event) => { - setState({ ...state, [event.target.name]: event.target.checked }) - } return ( <>
- - - } - label={t("toggleLabel")} - /> -