mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add flag to see all tokens if owner (#6227)
* added query for tokens by user id * updated query args * adding owner col * fix request params * update-golden-files * added owners col to ls table output * added ttoken translations * prettier * format table according to arg * using slice.Contains * refactored token state * cleanup
This commit is contained in:
Vendored
+1
@@ -113,6 +113,7 @@
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"tanstack",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
|
||||
+4
-1
@@ -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")
|
||||
|
||||
|
||||
+68
-9
@@ -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
|
||||
}
|
||||
|
||||
func listTokens() *cobra.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]codersdk.APIKey{}, nil),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
// 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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
+29
-8
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+20
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Default | <code>false</code> |
|
||||
|
||||
### --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
|
||||
<br/>
|
||||
| | |
|
||||
| --- | --- |
|
||||
|
||||
+7
-2
@@ -130,9 +130,14 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTokens = async (): Promise<TypesGen.APIKey[]> => {
|
||||
export const getTokens = async (
|
||||
params: TypesGen.TokensFilter,
|
||||
): Promise<TypesGen.APIKey[]> => {
|
||||
const response = await axios.get<TypesGen.APIKey[]>(
|
||||
"/api/v2/users/me/keys/tokens",
|
||||
`/api/v2/users/me/keys/tokens`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -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<boolean>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<PropsWithChildren<unknown>> = () => {
|
||||
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<string | undefined>(
|
||||
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")}{" "}
|
||||
<code className={styles.code}>coder tokens create</code> command.
|
||||
</>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<Typography>
|
||||
{Language.deleteDescription}
|
||||
{t("deleteToken.deleteCaption")}
|
||||
<br />
|
||||
<br />
|
||||
{deleteTokenId}
|
||||
{tokenIdToDelete}
|
||||
</Typography>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={Language.title} description={description} layout="fluid">
|
||||
<Section title={t("title")} description={description} layout="fluid">
|
||||
<TokensPageView
|
||||
tokens={tokens}
|
||||
isLoading={isLoading}
|
||||
hasLoaded={hasLoaded}
|
||||
isLoading={isFetching}
|
||||
hasLoaded={isFetched}
|
||||
getTokensError={getTokensError}
|
||||
onDelete={(id) => {
|
||||
tokensSend({ type: "DELETE_TOKEN", id })
|
||||
setTokenIdToDelete(id)
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<ConfirmDialog
|
||||
title={Language.deleteTitle}
|
||||
title={t("deleteToken.delete")}
|
||||
description={content}
|
||||
open={tokensState.matches("confirmTokenDelete")}
|
||||
confirmLoading={tokensState.matches("deletingToken")}
|
||||
open={Boolean(tokenIdToDelete) || isDeleting}
|
||||
confirmLoading={isDeleting}
|
||||
onConfirm={() => {
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<Stack>
|
||||
@@ -64,10 +57,10 @@ export const TokensPageView: FC<
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="25%">{Language.idLabel}</TableCell>
|
||||
<TableCell width="25%">{Language.createdAtLabel}</TableCell>
|
||||
<TableCell width="25%">{Language.lastUsedLabel}</TableCell>
|
||||
<TableCell width="25%">{Language.expiresAtLabel}</TableCell>
|
||||
<TableCell width="25%">{t("table.id")}</TableCell>
|
||||
<TableCell width="25%">{t("table.createdAt")}</TableCell>
|
||||
<TableCell width="25%">{t("table.lastUsed")}</TableCell>
|
||||
<TableCell width="25%">{t("table.expiresAt")}</TableCell>
|
||||
<TableCell width="0%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -77,7 +70,7 @@ export const TokensPageView: FC<
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
<Cond condition={hasLoaded && tokens?.length === 0}>
|
||||
<TableEmpty message={Language.emptyMessage} />
|
||||
<TableEmpty message={t("emptyState")} />
|
||||
</Cond>
|
||||
<Cond>
|
||||
{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")}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user