diff --git a/.vscode/settings.json b/.vscode/settings.json
index d4c34f3a9a106..b0dd6c524d9c8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -136,6 +136,7 @@
"thead",
"tios",
"tmpdir",
+ "tokenconfig",
"tparallel",
"trialer",
"trimprefix",
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 5785cb57f4ac8..815886602f9e9 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -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": [
@@ -8120,6 +8154,14 @@ const docTemplate = `{
}
}
},
+ "codersdk.TokenConfig": {
+ "type": "object",
+ "properties": {
+ "max_token_lifetime": {
+ "type": "integer"
+ }
+ }
+ },
"codersdk.TraceConfig": {
"type": "object",
"properties": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index dc7a9dd435e4a..8e3e6384727fa 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -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": {
diff --git a/coderd/apikey.go b/coderd/apikey.go
index 39b3fef29c16b..19867afb77a95 100644
--- a/coderd/apikey.go
+++ b/coderd/apikey.go
@@ -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.
diff --git a/coderd/coderd.go b/coderd/coderd.go
index ed53b095751e5..3a82a2a50bf36 100644
--- a/coderd/coderd.go
+++ b/coderd/coderd.go
@@ -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)
})
diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go
index 4758b1ee46923..41e4c810e7f98 100644
--- a/coderd/coderdtest/authorize.go
+++ b/coderd/coderdtest/authorize.go
@@ -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,
diff --git a/codersdk/apikey.go b/codersdk/apikey.go
index cba463084d645..82f26d5585b5d 100644
--- a/codersdk/apikey.go
+++ b/codersdk/apikey.go
@@ -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)
+}
diff --git a/docs/api/general.md b/docs/api/general.md
index 679b6405af156..1cc9a707051ad 100644
--- a/docs/api/general.md
+++ b/docs/api/general.md
@@ -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).
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 7ee906d6195b7..9567afa5555d8 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -3839,6 +3839,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
diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index e2a184cc599cf..ecca3f32e1e1f 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -127,6 +127,10 @@ const TemplateVariablesPage = lazy(
() => import("./pages/TemplateVariablesPage/TemplateVariablesPage"),
)
+const CreateTokenPage = lazy(
+ () => import("./pages/CreateTokenPage/CreateTokenPage"),
+)
+
export const AppRouter: FC = () => {
return (
}>
@@ -215,7 +219,10 @@ export const AppRouter: FC = () => {
} />
} />
} />
- } />
+
+ } />
+ } />
+
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index b28d9e0379c12..90dbbadcaf75e 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -153,10 +153,22 @@ export const getTokens = async (
return response.data
}
-export const deleteAPIKey = async (keyId: string): Promise => {
+export const deleteToken = async (keyId: string): Promise => {
await axios.delete("/api/v2/users/me/keys/" + keyId)
}
+export const createToken = async (
+ params: TypesGen.CreateTokenRequest,
+): Promise => {
+ const response = await axios.post(`/api/v2/users/me/keys/tokens`, params)
+ return response.data
+}
+
+export const getTokenConfig = async (): Promise => {
+ const response = await axios.get("/api/v2/users/me/keys/tokens/tokenconfig")
+ return response.data
+}
+
export const getUsers = async (
options: TypesGen.UsersRequest,
): Promise => {
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 9cb75fd64fa6d..d388d2c7dfada 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -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
diff --git a/site/src/components/Form/Form.tsx b/site/src/components/Form/Form.tsx
index 1c7e06c25513e..b30f276667101 100644
--- a/site/src/components/Form/Form.tsx
+++ b/site/src/components/Form/Form.tsx
@@ -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 (
-
+
void
+ onCancel?: () => void
}
export const FullPageHorizontalForm: FC<
@@ -23,9 +23,11 @@ export const FullPageHorizontalForm: FC<
- Cancel
-
+ onCancel && (
+
+ )
}
>
{title}
diff --git a/site/src/components/SettingsLayout/Section.tsx b/site/src/components/SettingsLayout/Section.tsx
index 0ebf343013726..2ff69ed20984d 100644
--- a/site/src/components/SettingsLayout/Section.tsx
+++ b/site/src/components/SettingsLayout/Section.tsx
@@ -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> & {
+type SectionFC = FC> & {
Action: typeof SectionAction
}
diff --git a/site/src/i18n/en/tokensPage.json b/site/src/i18n/en/tokensPage.json
index a6b4cc3f94d7e..4a2eaa2fe5ce2 100644
--- a/site/src/i18n/en/tokensPage.json
+++ b/site/src/i18n/en/tokensPage.json
@@ -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?
<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?
<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"
}
}
diff --git a/site/src/pages/CreateTokenPage/CreateTokenForm.tsx b/site/src/pages/CreateTokenPage/CreateTokenForm.tsx
new file mode 100644
index 0000000000000..059db01604bdd
--- /dev/null
+++ b/site/src/pages/CreateTokenPage/CreateTokenForm.tsx
@@ -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
+ maxTokenLifetime?: number
+ formError: Error | unknown
+ setFormError: (arg0: Error | unknown) => void
+ isCreating: boolean
+ creationFailed: boolean
+}
+
+export const CreateTokenForm: FC = ({
+ form,
+ maxTokenLifetime,
+ formError,
+ setFormError,
+ isCreating,
+ creationFailed,
+}) => {
+ const styles = useStyles()
+ const { t } = useTranslation("tokensPage")
+ const navigate = useNavigate()
+
+ const [expDays, setExpDays] = useState(1)
+ const [lifetimeDays, setLifetimeDays] = useState(
+ 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(form, formError)
+
+ return (
+
+
+
+ setFormError(undefined))}
+ autoFocus
+ fullWidth
+ variant="outlined"
+ />
+
+
+
+
+
+ {
+ void setLifetimeDays(event.target.value)
+ }}
+ fullWidth
+ InputLabelProps={{
+ shrink: true,
+ }}
+ >
+ {filterByMaxTokenLifetime(maxTokenLifetime).map((lt) => (
+
+ ))}
+
+
+
+ {lifetimeDays === "custom" && (
+ {
+ 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,
+ }}
+ />
+ )}
+
+
+
+ navigate("/settings/tokens")}
+ isLoading={isCreating}
+ submitLabel={
+ creationFailed
+ ? t("createToken.footer.retry")
+ : t("createToken.footer.submit")
+ }
+ />
+
+ )
+}
+
+const useStyles = makeStyles(() => ({
+ formSectionInfo: {
+ minWidth: "300px",
+ },
+}))
diff --git a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx
new file mode 100644
index 0000000000000..44c206d585eb4
--- /dev/null
+++ b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx
@@ -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(undefined)
+
+ const onCreateSuccess = () => {
+ displaySuccess(t("createToken.createSuccess"))
+ navigate("/settings/tokens")
+ }
+
+ const onCreateError = (error: unknown) => {
+ setFormError(error)
+ displayError(t("createToken.createError"))
+ }
+
+ const form = useFormik({
+ 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
+ }
+
+ return (
+ <>
+
+ {pageTitle(t("createToken.title"))}
+
+ {tokenFetchFailed && (
+
+ )}
+
+
+
+ >
+ )
+}
+
+export default CreateTokenPage
diff --git a/site/src/pages/CreateTokenPage/utils.test.tsx b/site/src/pages/CreateTokenPage/utils.test.tsx
new file mode 100644
index 0000000000000..2bdb20c1809e3
--- /dev/null
+++ b/site/src/pages/CreateTokenPage/utils.test.tsx
@@ -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)
+ },
+ )
+ })
+})
diff --git a/site/src/pages/CreateTokenPage/utils.ts b/site/src/pages/CreateTokenPage/utils.ts
new file mode 100644
index 0000000000000..a7dc42a39e607
--- /dev/null
+++ b/site/src/pages/CreateTokenPage/utils.ts
@@ -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"
+}
diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx
index b7af557668d7a..d1d0c2e61648f 100644
--- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx
+++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx
@@ -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> = () => {
const styles = useStyles()
@@ -18,11 +22,17 @@ export const TokensPage: FC> = () => {
)
+ const TokenActions = () => (
+
+ } component={RouterLink} to="new">
+ {t("tokenActions.addToken")}
+
+
+ )
+
const [tokenIdToDelete, setTokenIdToDelete] = useState(
undefined,
)
- const [viewAllTokens, setViewAllTokens] = useState(false)
- const { data: perms } = useCheckTokenPermissions()
const {
data: tokens,
@@ -31,7 +41,9 @@ export const TokensPage: FC> = () => {
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> = () => {
description={description}
layout="fluid"
>
-
+
({
borderRadius: 2,
},
},
+ tokenActions: {
+ marginBottom: theme.spacing(1),
+ },
}))
export default TokensPage
diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
index 547fecc1133d1..f62c70f6e6a23 100644
--- a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
+++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
@@ -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
> = ({
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 (
@@ -60,13 +57,11 @@ export const TokensPageView: FC<
- {t("table.id")}
- {t("table.createdAt")}
- {t("table.lastUsed")}
- {t("table.expiresAt")}
- {viewAllTokens && (
- {t("table.owner")}
- )}
+ {t("table.id")}
+ {t("table.name")}
+ {t("table.lastUsed")}
+ {t("table.expiresAt")}
+ {t("table.createdAt")}
@@ -94,7 +89,7 @@ export const TokensPageView: FC<
- {dayjs(token.created_at).fromNow()}
+ {token.token_name}
@@ -108,13 +103,13 @@ export const TokensPageView: FC<
{dayjs(token.expires_at).fromNow()}
- {viewAllTokens && (
-
-
- {token.username}
-
-
- )}
+
+
+
+ {dayjs(token.created_at).fromNow()}
+
+
+
diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx b/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx
index 4e34aa524399f..166836c31668e 100644
--- a/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx
+++ b/site/src/pages/UserSettingsPage/TokensPage/components/ConfirmDeleteDialog.tsx
@@ -13,7 +13,11 @@ export const ConfirmDeleteDialog: FC<{
const { t } = useTranslation("tokensPage")
const description = (
-
+
Are you sure you want to delete this token?
@@ -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 (
void
-}> = ({ hasReadAll, viewAllTokens, setViewAllTokens }) => {
- const styles = useStyles()
- const { t } = useTranslation("tokensPage")
-
- return (
-
- {hasReadAll && (
- setViewAllTokens(!viewAllTokens)}
- name="viewAllTokens"
- color="primary"
- />
- }
- label={t("toggleLabel")}
- />
- )}
-
- )
-}
-
-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",
- },
- },
-}))
diff --git a/site/src/pages/UserSettingsPage/TokensPage/components/index.ts b/site/src/pages/UserSettingsPage/TokensPage/components/index.ts
index e58a46323c61a..55b192231a325 100644
--- a/site/src/pages/UserSettingsPage/TokensPage/components/index.ts
+++ b/site/src/pages/UserSettingsPage/TokensPage/components/index.ts
@@ -1,2 +1 @@
export { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"
-export { TokensSwitch } from "./TokensSwitch"
diff --git a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts
index ec69a8a59ca8c..38b23c73543a7 100644
--- a/site/src/pages/UserSettingsPage/TokensPage/hooks.ts
+++ b/site/src/pages/UserSettingsPage/TokensPage/hooks.ts
@@ -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)
diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts
index 2a6f7eb8f2853..f4ca69563f7c9 100644
--- a/site/src/util/formUtils.ts
+++ b/site/src/util/formUtils.ts
@@ -69,10 +69,11 @@ export const getFormHelpers =
}
export const onChangeTrimmed =
- (form: FormikContextType) =>
+ (form: FormikContextType, callback?: () => void) =>
(event: ChangeEvent): void => {
event.target.value = event.target.value.trim()
form.handleChange(event)
+ callback && callback()
}
// REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go