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:
Kira Pilot
2023-02-23 07:00:27 -08:00
committed by GitHub
parent 7a52a9cfc8
commit a32169ccb5
22 changed files with 348 additions and 214 deletions
+1
View File
@@ -113,6 +113,7 @@
"stretchr",
"STTY",
"stuntest",
"tanstack",
"tailbroker",
"tailcfg",
"tailexchange",
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -5
View File
@@ -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))
+3 -1
View File
@@ -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]
+4
View File
@@ -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)
}
+12
View File
@@ -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)})
+13
View File
@@ -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()
+1
View File
@@ -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
+44
View File
@@ -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
`
+3
View File
@@ -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
View File
@@ -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
}
+9 -1
View File
@@ -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
View File
@@ -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
}
+5
View File
@@ -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>
+2
View File
@@ -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,
}
+18
View File
@@ -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)
},
})
}
-139
View File
@@ -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)
},
},
},
)