feat(site): add ability to create tokens from account tokens page (#6608)

* add token actions

* added basic token form

* removed token switch

* refined date field

* limiting lifetime days to maxTokenLifetime

* broke apart files

* added loader and error

* fixed form layout

* added some unit tests

* fixed be tests

* no authorize check
This commit is contained in:
Kira Pilot 2023-03-16 08:25:08 -07:00 committed by GitHub
parent af618477bd
commit 811a69f371
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 737 additions and 131 deletions

View File

@ -136,6 +136,7 @@
"thead",
"tios",
"tmpdir",
"tokenconfig",
"tparallel",
"trialer",
"trimprefix",

42
coderd/apidoc/docs.go generated
View File

@ -3362,6 +3362,40 @@ const docTemplate = `{
}
}
},
"/users/{user}/keys/tokens/tokenconfig": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"General"
],
"summary": "Get token config",
"operationId": "get-token-config",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.TokenConfig"
}
}
}
}
},
"/users/{user}/keys/tokens/{keyname}": {
"get": {
"security": [
@ -8121,6 +8155,14 @@ const docTemplate = `{
}
}
},
"codersdk.TokenConfig": {
"type": "object",
"properties": {
"max_token_lifetime": {
"type": "integer"
}
}
},
"codersdk.TraceConfig": {
"type": "object",
"properties": {

View File

@ -2952,6 +2952,36 @@
}
}
},
"/users/{user}/keys/tokens/tokenconfig": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["General"],
"summary": "Get token config",
"operationId": "get-token-config",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.TokenConfig"
}
}
}
}
},
"/users/{user}/keys/tokens/{keyname}": {
"get": {
"security": [
@ -7303,6 +7333,14 @@
}
}
},
"codersdk.TokenConfig": {
"type": "object",
"properties": {
"max_token_lifetime": {
"type": "integer"
}
}
},
"codersdk.TraceConfig": {
"type": "object",
"properties": {

View File

@ -6,6 +6,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"net"
"net/http"
"strconv"
@ -339,6 +340,38 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
// @Summary Get token config
// @ID get-token-config
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Param user path string true "User ID, name, or me"
// @Success 200 {object} codersdk.TokenConfig
// @Router /users/{user}/keys/tokens/tokenconfig [get]
func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) {
values, err := api.DeploymentValues.WithoutSecrets()
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
var maxTokenLifetime time.Duration
// if --max-token-lifetime is unset (default value is math.MaxInt64)
// send back a falsy value
if values.MaxTokenLifetime.Value() == time.Duration(math.MaxInt64) {
maxTokenLifetime = 0
} else {
maxTokenLifetime = values.MaxTokenLifetime.Value()
}
httpapi.Write(
r.Context(), rw, http.StatusOK,
codersdk.TokenConfig{
MaxTokenLifetime: maxTokenLifetime,
},
)
}
// Generates a new ID and secret for an API key.
func GenerateAPIKeyIDSecret() (id string, secret string, err error) {
// Length of an API Key ID.

View File

@ -561,6 +561,7 @@ func New(options *Options) *API {
r.Route("/tokens", func(r chi.Router) {
r.Post("/", api.postToken)
r.Get("/", api.tokens)
r.Get("/tokenconfig", api.tokenConfig)
r.Route("/{keyname}", func(r chi.Router) {
r.Get("/", api.apiKeyByName)
})

View File

@ -99,6 +99,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
AssertObject: rbac.ResourceAPIKey,
AssertAction: rbac.ActionRead,
},
"GET:/api/v2/users/{user}/keys/tokens/tokenconfig": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,

View File

@ -97,6 +97,10 @@ type APIKeyWithOwner struct {
Username string `json:"username"`
}
type TokenConfig struct {
MaxTokenLifetime time.Duration `json:"max_token_lifetime"`
}
// asRequestOption returns a function that can be used in (*Client).Request.
// It modifies the request query parameters.
func (f TokensFilter) asRequestOption() RequestOption {
@ -161,3 +165,17 @@ func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) err
}
return nil
}
// GetTokenConfig returns deployment options related to token management
func (c *Client) GetTokenConfig(ctx context.Context, userID string) (TokenConfig, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens/tokenconfig", userID), nil)
if err != nil {
return TokenConfig{}, err
}
defer res.Body.Close()
if res.StatusCode > http.StatusOK {
return TokenConfig{}, ReadBodyAsError(res)
}
tokenConfig := TokenConfig{}
return tokenConfig, json.NewDecoder(res.Body).Decode(&tokenConfig)
}

View File

@ -516,3 +516,40 @@ curl -X GET http://coder-server:8080/api/v2/updatecheck \
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UpdateCheckResponse](schemas.md#codersdkupdatecheckresponse) |
## Get token config
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/tokenconfig \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/{user}/keys/tokens/tokenconfig`
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | ------ | -------- | -------------------- |
| `user` | path | string | true | User ID, name, or me |
### Example responses
> 200 Response
```json
{
"max_token_lifetime": 0
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TokenConfig](schemas.md#codersdktokenconfig) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).

View File

@ -3840,6 +3840,20 @@ Parameter represents a set value for the scope.
| `type` | `number` |
| `type` | `bool` |
## codersdk.TokenConfig
```json
{
"max_token_lifetime": 0
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------------- | ------- | -------- | ------------ | ----------- |
| `max_token_lifetime` | integer | false | | |
## codersdk.TraceConfig
```json

View File

@ -129,6 +129,10 @@ const WorkspaceSettingsPage = lazy(
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
)
const CreateTokenPage = lazy(
() => import("./pages/CreateTokenPage/CreateTokenPage"),
)
export const AppRouter: FC = () => {
return (
<Suspense fallback={<FullScreenLoader />}>
@ -217,7 +221,10 @@ export const AppRouter: FC = () => {
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tokens" element={<TokensPage />} />
<Route path="tokens">
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
</Route>
</Route>
<Route path="/@:username">

View File

@ -153,10 +153,22 @@ export const getTokens = async (
return response.data
}
export const deleteAPIKey = async (keyId: string): Promise<void> => {
export const deleteToken = async (keyId: string): Promise<void> => {
await axios.delete("/api/v2/users/me/keys/" + keyId)
}
export const createToken = async (
params: TypesGen.CreateTokenRequest,
): Promise<TypesGen.GenerateAPIKeyResponse> => {
const response = await axios.post(`/api/v2/users/me/keys/tokens`, params)
return response.data
}
export const getTokenConfig = async (): Promise<TypesGen.TokenConfig> => {
const response = await axios.get("/api/v2/users/me/keys/tokens/tokenconfig")
return response.data
}
export const getUsers = async (
options: TypesGen.UsersRequest,
): Promise<TypesGen.GetUsersResponse> => {

View File

@ -847,6 +847,12 @@ export interface TemplateVersionsByTemplateRequest extends Pagination {
readonly template_id: string
}
// From codersdk/apikey.go
export interface TokenConfig {
// This is likely an enum in an external package ("time.Duration")
readonly max_token_lifetime: number
}
// From codersdk/apikey.go
export interface TokensFilter {
readonly include_all: boolean

View File

@ -64,6 +64,7 @@ export const FormSection: FC<
description: string | JSX.Element
classes?: {
root?: string
sectionInfo?: string
infoTitle?: string
}
}
@ -73,7 +74,12 @@ export const FormSection: FC<
return (
<div className={combineClasses([styles.formSection, classes.root])}>
<div className={styles.formSectionInfo}>
<div
className={combineClasses([
classes.sectionInfo,
styles.formSectionInfo,
])}
>
<h2
className={combineClasses([
styles.formSectionInfoTitle,

View File

@ -11,7 +11,7 @@ import { makeStyles } from "@material-ui/core/styles"
export interface FullPageHorizontalFormProps {
title: string
detail?: ReactNode
onCancel: () => void
onCancel?: () => void
}
export const FullPageHorizontalForm: FC<
@ -23,9 +23,11 @@ export const FullPageHorizontalForm: FC<
<Margins size="medium">
<PageHeader
actions={
<Button variant="outlined" size="small" onClick={onCancel}>
Cancel
</Button>
onCancel && (
<Button variant="outlined" size="small" onClick={onCancel}>
Cancel
</Button>
)
}
>
<PageHeaderTitle>{title}</PageHeaderTitle>

View File

@ -1,21 +1,21 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import { FC } from "react"
import { FC, ReactNode, PropsWithChildren } from "react"
import { SectionAction } from "../SectionAction/SectionAction"
type SectionLayout = "fixed" | "fluid"
export interface SectionProps {
title?: React.ReactNode | string
description?: React.ReactNode
toolbar?: React.ReactNode
alert?: React.ReactNode
title?: ReactNode | string
description?: ReactNode
toolbar?: ReactNode
alert?: ReactNode
layout?: SectionLayout
className?: string
children?: React.ReactNode
children?: ReactNode
}
type SectionFC = FC<React.PropsWithChildren<SectionProps>> & {
type SectionFC = FC<PropsWithChildren<SectionProps>> & {
Action: typeof SectionAction
}

View File

@ -1,19 +1,51 @@
{
"title": "Tokens",
"description": "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the <1>{{cliCreateCommand}}</1> command.",
"description": "Tokens are used to authenticate with the Coder API. You can create a token on this page or with the Coder CLI using the <1>{{cliCreateCommand}}</1> command.",
"emptyState": "No tokens found",
"deleteToken": {
"delete": "Delete Token",
"deleteCaption": "Are you sure you want to delete this token?<br/><br/><4>{{tokenId}}</4>",
"deleteSuccess": "Token has been deleted",
"deleteFailure": "Failed to delete token"
"tokenActions": {
"addToken": "Add token",
"deleteToken": {
"delete": "Delete Token",
"deleteCaption": "Are you sure you want to delete this token?<br/><br/><4>{{tokenId}}</4>",
"deleteSuccess": "Token has been deleted",
"deleteFailure": "Failed to delete token"
}
},
"toggleLabel": "Show all tokens",
"table": {
"id": "ID",
"createdAt": "Created At",
"name": "Name",
"lastUsed": "Last Used",
"expiresAt": "Expires At",
"owner": "Owner"
"createdAt": "Created At"
},
"createToken": {
"title": "Create Token",
"detail": "All tokens are unscoped and therefore have full resource access.",
"nameSection": {
"title": "Name",
"description": "What is this token for?"
},
"lifetimeSection": {
"title": "Expiration",
"description": "The token will expire on {{date}}.",
"emptyDescription": "Please set a token expiration.",
"7": "7 days",
"30": "30 days",
"60": "60 days",
"90": "90 days",
"custom": "Custom",
"noExpiration": "No expiration",
"expiresOn": "Expires on"
},
"fields": {
"name": "Name",
"lifetime": "Lifetime"
},
"footer": {
"retry": "Retry",
"submit": "Create token"
},
"createSuccess": "Token has been created",
"createError": "Failed to create token"
}
}

View File

@ -0,0 +1,170 @@
import { FC, useState, useEffect } from "react"
import {
FormFields,
FormSection,
FormFooter,
HorizontalForm,
} from "components/Form/Form"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { useTranslation } from "react-i18next"
import { onChangeTrimmed, getFormHelpers } from "util/formUtils"
import TextField from "@material-ui/core/TextField"
import MenuItem from "@material-ui/core/MenuItem"
import {
NANO_HOUR,
CreateTokenData,
determineDefaultLtValue,
filterByMaxTokenLifetime,
customLifetimeDay,
} from "./utils"
import { FormikContextType } from "formik"
import dayjs from "dayjs"
import { useNavigate } from "react-router-dom"
import { Stack } from "components/Stack/Stack"
interface CreateTokenFormProps {
form: FormikContextType<CreateTokenData>
maxTokenLifetime?: number
formError: Error | unknown
setFormError: (arg0: Error | unknown) => void
isCreating: boolean
creationFailed: boolean
}
export const CreateTokenForm: FC<CreateTokenFormProps> = ({
form,
maxTokenLifetime,
formError,
setFormError,
isCreating,
creationFailed,
}) => {
const styles = useStyles()
const { t } = useTranslation("tokensPage")
const navigate = useNavigate()
const [expDays, setExpDays] = useState<number>(1)
const [lifetimeDays, setLifetimeDays] = useState<number | string>(
determineDefaultLtValue(maxTokenLifetime),
)
useEffect(() => {
if (lifetimeDays !== "custom") {
void form.setFieldValue("lifetime", lifetimeDays)
} else {
void form.setFieldValue("lifetime", expDays)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- adding form will cause an infinite loop
}, [lifetimeDays, expDays])
const getFieldHelpers = getFormHelpers<CreateTokenData>(form, formError)
return (
<HorizontalForm onSubmit={form.handleSubmit}>
<FormSection
title={t("createToken.nameSection.title")}
description={t("createToken.nameSection.description")}
classes={{ sectionInfo: styles.formSectionInfo }}
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
label={t("createToken.fields.name")}
required
onChange={onChangeTrimmed(form, () => setFormError(undefined))}
autoFocus
fullWidth
variant="outlined"
/>
</FormFields>
</FormSection>
<FormSection
title={t("createToken.lifetimeSection.title")}
description={
form.values.lifetime
? t("createToken.lifetimeSection.description", {
date: dayjs()
.add(form.values.lifetime, "days")
.utc()
.format("MMMM DD, YYYY"),
})
: t("createToken.lifetimeSection.emptyDescription")
}
classes={{ sectionInfo: styles.formSectionInfo }}
>
<FormFields>
<Stack direction="row">
<TextField
select
label={t("createToken.fields.lifetime")}
required
defaultValue={determineDefaultLtValue(maxTokenLifetime)}
onChange={(event) => {
void setLifetimeDays(event.target.value)
}}
fullWidth
InputLabelProps={{
shrink: true,
}}
>
{filterByMaxTokenLifetime(maxTokenLifetime).map((lt) => (
<MenuItem key={lt.label} value={lt.value}>
{lt.label}
</MenuItem>
))}
<MenuItem
key={customLifetimeDay.label}
value={customLifetimeDay.value}
>
{customLifetimeDay.label}
</MenuItem>
</TextField>
{lifetimeDays === "custom" && (
<TextField
type="date"
label={t("createToken.lifetimeSection.expiresOn")}
defaultValue={dayjs().add(expDays, "day").format("YYYY-MM-DD")}
onChange={(event) => {
const lt = Math.ceil(
dayjs(event.target.value).diff(dayjs(), "day", true),
)
setExpDays(lt)
}}
inputProps={{
min: dayjs().add(1, "day").format("YYYY-MM-DD"),
max: maxTokenLifetime
? dayjs()
.add(maxTokenLifetime / NANO_HOUR / 24, "day")
.format("YYYY-MM-DD")
: undefined,
required: true,
}}
fullWidth
InputLabelProps={{
shrink: true,
required: true,
}}
/>
)}
</Stack>
</FormFields>
</FormSection>
<FormFooter
onCancel={() => navigate("/settings/tokens")}
isLoading={isCreating}
submitLabel={
creationFailed
? t("createToken.footer.retry")
: t("createToken.footer.submit")
}
/>
</HorizontalForm>
)
}
const useStyles = makeStyles(() => ({
formSectionInfo: {
minWidth: "300px",
},
}))

View File

@ -0,0 +1,98 @@
import { FC, useState } from "react"
import { useTranslation } from "react-i18next"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { useNavigate } from "react-router-dom"
import { useFormik } from "formik"
import { Loader } from "components/Loader/Loader"
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils"
import { useMutation, useQuery } from "@tanstack/react-query"
import { createToken, getTokenConfig } from "api/api"
import { CreateTokenForm } from "./CreateTokenForm"
import { NANO_HOUR, CreateTokenData } from "./utils"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
const initialValues: CreateTokenData = {
name: "",
lifetime: 30,
}
const CreateTokenPage: FC = () => {
const { t } = useTranslation("tokensPage")
const navigate = useNavigate()
const {
mutate: saveToken,
isLoading: isCreating,
isError: creationFailed,
} = useMutation(createToken)
const {
data: tokenConfig,
isLoading: fetchingTokenConfig,
isError: tokenFetchFailed,
error: tokenFetchError,
} = useQuery({
queryKey: ["tokenconfig"],
queryFn: getTokenConfig,
})
const [formError, setFormError] = useState<unknown | undefined>(undefined)
const onCreateSuccess = () => {
displaySuccess(t("createToken.createSuccess"))
navigate("/settings/tokens")
}
const onCreateError = (error: unknown) => {
setFormError(error)
displayError(t("createToken.createError"))
}
const form = useFormik<CreateTokenData>({
initialValues,
onSubmit: (values) => {
saveToken(
{
lifetime: values.lifetime * 24 * NANO_HOUR,
token_name: values.name,
scope: "all", // tokens are currently unscoped
},
{
onError: onCreateError,
onSuccess: onCreateSuccess,
},
)
},
})
if (fetchingTokenConfig) {
return <Loader />
}
return (
<>
<Helmet>
<title>{pageTitle(t("createToken.title"))}</title>
</Helmet>
{tokenFetchFailed && (
<AlertBanner severity="error" error={tokenFetchError} />
)}
<FullPageHorizontalForm
title={t("createToken.title")}
detail={t("createToken.detail")}
>
<CreateTokenForm
form={form}
maxTokenLifetime={tokenConfig?.max_token_lifetime}
formError={formError}
setFormError={setFormError}
isCreating={isCreating}
creationFailed={creationFailed}
/>
</FullPageHorizontalForm>
</>
)
}
export default CreateTokenPage

View File

@ -0,0 +1,75 @@
import {
filterByMaxTokenLifetime,
determineDefaultLtValue,
lifetimeDayPresets,
LifetimeDay,
NANO_HOUR,
} from "./utils"
describe("unit/CreateTokenForm", () => {
describe("filterByMaxTokenLifetime", () => {
it.each<{
maxTokenLifetime: number
expected: LifetimeDay[]
}>([
{
maxTokenLifetime: 0,
expected: lifetimeDayPresets,
},
{ maxTokenLifetime: 6 * 24 * NANO_HOUR, expected: [] },
{
maxTokenLifetime: 20 * 24 * NANO_HOUR,
expected: [lifetimeDayPresets[0]],
},
{
maxTokenLifetime: 40 * 24 * NANO_HOUR,
expected: [lifetimeDayPresets[0], lifetimeDayPresets[1]],
},
{
maxTokenLifetime: 70 * 24 * NANO_HOUR,
expected: [
lifetimeDayPresets[0],
lifetimeDayPresets[1],
lifetimeDayPresets[2],
],
},
{
maxTokenLifetime: 100 * 24 * NANO_HOUR,
expected: lifetimeDayPresets,
},
])(
`filterByMaxTokenLifetime($maxTokenLifetime)`,
({ maxTokenLifetime, expected }) => {
expect(filterByMaxTokenLifetime(maxTokenLifetime)).toEqual(expected)
},
)
})
describe("determineDefaultLtValue", () => {
it.each<{
maxTokenLifetime: number
expected: string | number
}>([
{
maxTokenLifetime: 0,
expected: 30,
},
{
maxTokenLifetime: 60 * 24 * NANO_HOUR,
expected: 30,
},
{
maxTokenLifetime: 20 * 24 * NANO_HOUR,
expected: 7,
},
{
maxTokenLifetime: 2 * 24 * NANO_HOUR,
expected: "custom",
},
])(
`determineDefaultLtValue($maxTokenLifetime)`,
({ maxTokenLifetime, expected }) => {
expect(determineDefaultLtValue(maxTokenLifetime)).toEqual(expected)
},
)
})
})

View File

@ -0,0 +1,71 @@
import i18next from "i18next"
export const NANO_HOUR = 3600000000000
export interface CreateTokenData {
name: string
lifetime: number
}
export interface LifetimeDay {
label: string
value: number | string
}
export const lifetimeDayPresets: LifetimeDay[] = [
{
label: i18next.t("tokensPage:createToken.lifetimeSection.7"),
value: 7,
},
{
label: i18next.t("tokensPage:createToken.lifetimeSection.30"),
value: 30,
},
{
label: i18next.t("tokensPage:createToken.lifetimeSection.60"),
value: 60,
},
{
label: i18next.t("tokensPage:createToken.lifetimeSection.90"),
value: 90,
},
]
export const customLifetimeDay: LifetimeDay = {
label: i18next.t("tokensPage:createToken.lifetimeSection.custom"),
value: "custom",
}
export const filterByMaxTokenLifetime = (
maxTokenLifetime?: number,
): LifetimeDay[] => {
// if maxTokenLifetime hasn't been set, return the full array of options
if (!maxTokenLifetime) {
return lifetimeDayPresets
}
// otherwise only return options that are less than or equal to the max lifetime
return lifetimeDayPresets.filter(
(lifetime) => lifetime.value <= maxTokenLifetime / NANO_HOUR / 24,
)
}
export const determineDefaultLtValue = (
maxTokenLifetime?: number,
): string | number => {
const filteredArr = filterByMaxTokenLifetime(maxTokenLifetime)
// default to a lifetime of 30 days if within the maxTokenLifetime
const thirtyDayDefault = filteredArr.find((lt) => lt.value === 30)
if (thirtyDayDefault) {
return thirtyDayDefault.value
}
// otherwise default to the first preset option
if (filteredArr[0]) {
return filteredArr[0].value
}
// if no preset options are within the maxTokenLifetime, default to "custom"
return "custom"
}

View File

@ -3,8 +3,12 @@ import { Section } from "components/SettingsLayout/Section"
import { TokensPageView } from "./TokensPageView"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { useTranslation, Trans } from "react-i18next"
import { useTokensData, useCheckTokenPermissions } from "./hooks"
import { TokensSwitch, ConfirmDeleteDialog } from "./components"
import { useTokensData } from "./hooks"
import { ConfirmDeleteDialog } from "./components"
import { Stack } from "components/Stack/Stack"
import Button from "@material-ui/core/Button"
import { Link as RouterLink } from "react-router-dom"
import AddIcon from "@material-ui/icons/AddOutlined"
export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
const styles = useStyles()
@ -18,11 +22,17 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
</Trans>
)
const TokenActions = () => (
<Stack direction="row" justifyContent="end" className={styles.tokenActions}>
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
{t("tokenActions.addToken")}
</Button>
</Stack>
)
const [tokenIdToDelete, setTokenIdToDelete] = useState<string | undefined>(
undefined,
)
const [viewAllTokens, setViewAllTokens] = useState<boolean>(false)
const { data: perms } = useCheckTokenPermissions()
const {
data: tokens,
@ -31,7 +41,9 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
isFetched,
queryKey,
} = useTokensData({
include_all: viewAllTokens,
// we currently do not show all tokens in the UI, even if
// the user has read all permissions
include_all: false,
})
return (
@ -42,14 +54,9 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
description={description}
layout="fluid"
>
<TokensSwitch
hasReadAll={perms?.readAllApiKeys ?? false}
viewAllTokens={viewAllTokens}
setViewAllTokens={setViewAllTokens}
/>
<TokenActions />
<TokensPageView
tokens={tokens}
viewAllTokens={viewAllTokens}
isLoading={isFetching}
hasLoaded={isFetched}
getTokensError={getTokensError}
@ -77,6 +84,9 @@ const useStyles = makeStyles((theme) => ({
borderRadius: 2,
},
},
tokenActions: {
marginBottom: theme.spacing(1),
},
}))
export default TokensPage

View File

@ -25,7 +25,6 @@ const lastUsedOrNever = (lastUsed: string) => {
export interface TokensPageViewProps {
tokens?: APIKeyWithOwner[]
viewAllTokens: boolean
getTokensError?: Error | unknown
isLoading: boolean
hasLoaded: boolean
@ -37,7 +36,6 @@ export const TokensPageView: FC<
React.PropsWithChildren<TokensPageViewProps>
> = ({
tokens,
viewAllTokens,
getTokensError,
isLoading,
hasLoaded,
@ -46,7 +44,6 @@ export const TokensPageView: FC<
}) => {
const theme = useTheme()
const { t } = useTranslation("tokensPage")
const colWidth = viewAllTokens ? "20%" : "25%"
return (
<Stack>
@ -60,13 +57,11 @@ export const TokensPageView: FC<
<Table>
<TableHead>
<TableRow>
<TableCell width={colWidth}>{t("table.id")}</TableCell>
<TableCell width={colWidth}>{t("table.createdAt")}</TableCell>
<TableCell width={colWidth}>{t("table.lastUsed")}</TableCell>
<TableCell width={colWidth}>{t("table.expiresAt")}</TableCell>
{viewAllTokens && (
<TableCell width="20%">{t("table.owner")}</TableCell>
)}
<TableCell width="20%">{t("table.id")}</TableCell>
<TableCell width="20%">{t("table.name")}</TableCell>
<TableCell width="20%">{t("table.lastUsed")}</TableCell>
<TableCell width="20%">{t("table.expiresAt")}</TableCell>
<TableCell width="20%">{t("table.createdAt")}</TableCell>
<TableCell width="0%"></TableCell>
</TableRow>
</TableHead>
@ -94,7 +89,7 @@ export const TokensPageView: FC<
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{dayjs(token.created_at).fromNow()}
{token.token_name}
</span>
</TableCell>
@ -108,13 +103,13 @@ export const TokensPageView: FC<
{dayjs(token.expires_at).fromNow()}
</span>
</TableCell>
{viewAllTokens && (
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.username}
</span>
</TableCell>
)}
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{dayjs(token.created_at).fromNow()}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
<IconButton
@ -122,7 +117,7 @@ export const TokensPageView: FC<
onDelete(token.id)
}}
size="medium"
aria-label={t("deleteToken.delete")}
aria-label={t("tokenActions.deleteToken.delete")}
>
<DeleteOutlineIcon />
</IconButton>

View File

@ -13,7 +13,11 @@ export const ConfirmDeleteDialog: FC<{
const { t } = useTranslation("tokensPage")
const description = (
<Trans t={t} i18nKey="deleteToken.deleteCaption" values={{ tokenId }}>
<Trans
t={t}
i18nKey="tokenActions.deleteToken.deleteCaption"
values={{ tokenId }}
>
Are you sure you want to delete this token?
<br />
<br />
@ -25,19 +29,22 @@ export const ConfirmDeleteDialog: FC<{
useDeleteToken(queryKey)
const onDeleteSuccess = () => {
displaySuccess(t("deleteToken.deleteSuccess"))
displaySuccess(t("tokenActions.deleteToken.deleteSuccess"))
setTokenId(undefined)
}
const onDeleteError = (error: unknown) => {
const message = getErrorMessage(error, t("deleteToken.deleteFailure"))
const message = getErrorMessage(
error,
t("tokenActions.deleteToken.deleteFailure"),
)
displayError(message)
setTokenId(undefined)
}
return (
<ConfirmDialog
title={t("deleteToken.delete")}
title={t("tokenActions.deleteToken.delete")}
description={description}
open={Boolean(tokenId) || isDeleting}
confirmLoading={isDeleting}

View File

@ -1,48 +0,0 @@
import { FC } from "react"
import Switch from "@material-ui/core/Switch"
import FormGroup from "@material-ui/core/FormGroup"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { useTranslation } from "react-i18next"
export const TokensSwitch: FC<{
hasReadAll: boolean
viewAllTokens: boolean
setViewAllTokens: (arg: boolean) => void
}> = ({ hasReadAll, viewAllTokens, setViewAllTokens }) => {
const styles = useStyles()
const { t } = useTranslation("tokensPage")
return (
<FormGroup row className={styles.formRow}>
{hasReadAll && (
<FormControlLabel
control={
<Switch
className={styles.selectAllSwitch}
checked={viewAllTokens}
onChange={() => setViewAllTokens(!viewAllTokens)}
name="viewAllTokens"
color="primary"
/>
}
label={t("toggleLabel")}
/>
)}
</FormGroup>
)
}
const useStyles = makeStyles(() => ({
formRow: {
justifyContent: "end",
marginBottom: "10px",
},
selectAllSwitch: {
// decrease the hover state on the switch
// so that it isn't hidden behind the container
"& .MuiIconButton-root": {
padding: "8px",
},
},
}))

View File

@ -1,2 +1 @@
export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"
export { TokensSwitch } from "./TokensSwitch"

View File

@ -4,31 +4,9 @@ import {
useQueryClient,
QueryKey,
} from "@tanstack/react-query"
import { getTokens, deleteAPIKey, checkAuthorization } from "api/api"
import { getTokens, deleteToken } from "api/api"
import { TokensFilter } from "api/typesGenerated"
// Owners have the ability to read all API tokens,
// whereas members can only see the tokens they have created.
// We check permissions here to determine whether to display the
// 'View All' switch on the TokensPage.
export const useCheckTokenPermissions = () => {
const queryKey = ["auth"]
const params = {
checks: {
readAllApiKeys: {
object: {
resource_type: "api_key",
},
action: "read",
},
},
}
return useQuery({
queryKey,
queryFn: () => checkAuthorization(params),
})
}
// Load all tokens
export const useTokensData = ({ include_all }: TokensFilter) => {
const queryKey = ["tokens", include_all]
@ -51,7 +29,7 @@ export const useDeleteToken = (queryKey: QueryKey) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteAPIKey,
mutationFn: deleteToken,
onSuccess: () => {
// Invalidate and refetch
void queryClient.invalidateQueries(queryKey)

View File

@ -69,10 +69,11 @@ export const getFormHelpers =
}
export const onChangeTrimmed =
<T>(form: FormikContextType<T>) =>
<T>(form: FormikContextType<T>, callback?: () => void) =>
(event: ChangeEvent<HTMLInputElement>): void => {
event.target.value = event.target.value.trim()
form.handleChange(event)
callback && callback()
}
// REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go