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}} 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}} command.", "emptyState": "No tokens found", - "deleteToken": { - "delete": "Delete Token", - "deleteCaption": "Are you sure you want to delete this token?

<4>{{tokenId}}", - "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}}", + "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) => ( + + {lt.label} + + ))} + + {customLifetimeDay.label} + + + + {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 = () => ( + + + + ) + 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