mirror of https://github.com/coder/coder.git
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:
parent
af618477bd
commit
811a69f371
|
@ -136,6 +136,7 @@
|
|||
"thead",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tokenconfig",
|
||||
"tparallel",
|
||||
"trialer",
|
||||
"trimprefix",
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}))
|
|
@ -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
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}))
|
|
@ -1,2 +1 @@
|
|||
export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"
|
||||
export { TokensSwitch } from "./TokensSwitch"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue