diff --git a/coderd/apikey.go b/coderd/apikey.go index 5e9aee7c58e66..dea3525965043 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -189,10 +189,6 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { } keys, err := api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusOK, []codersdk.APIKey{}) - return - } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching API keys.", @@ -201,7 +197,7 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { return } - var apiKeys []codersdk.APIKey + apiKeys := []codersdk.APIKey{} for _, key := range keys { apiKeys = append(apiKeys, convertAPIKey(key)) } diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 459998d53aa2d..3c7ce3c828d00 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -39,6 +39,9 @@ const SecurityPage = lazy( const SSHKeysPage = lazy( () => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"), ) +const TokensPage = lazy( + () => import("./pages/UserSettingsPage/TokensPage/TokensPage"), +) const CreateUserPage = lazy( () => import("./pages/UsersPage/CreateUserPage/CreateUserPage"), ) @@ -219,6 +222,7 @@ export const AppRouter: FC = () => { } /> } /> } /> + } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 88a19561d436a..5b32f38b572b5 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -133,6 +133,18 @@ export const getApiKey = async (): Promise => { return response.data } +export const getTokens = async (): Promise => { + const response = await axios.get( + "/api/v2/users/me/keys/tokens", + ) + return response.data +} + +export const deleteAPIKey = async (keyId: string): Promise => { + const response = await axios.delete("/api/v2/users/me/keys/" + keyId) + return response.data +} + export const getUsers = async ( options: TypesGen.UsersRequest, ): Promise => { diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx index ed840fbe6ee70..99e9f471983ae 100644 --- a/site/src/components/SettingsLayout/Sidebar.tsx +++ b/site/src/components/SettingsLayout/Sidebar.tsx @@ -1,5 +1,6 @@ import { makeStyles } from "@material-ui/core/styles" import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined" +import FingerprintOutlinedIcon from "@material-ui/icons/FingerprintOutlined" import { User } from "api/typesGenerated" import { Stack } from "components/Stack/Stack" import { UserAvatar } from "components/UserAvatar/UserAvatar" @@ -65,10 +66,16 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => { } + icon={} > SSH Keys + } + > + Tokens + ) } diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx new file mode 100644 index 0000000000000..169464c07f7ca --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -0,0 +1,80 @@ +import { FC, PropsWithChildren } from "react" +import { Section } from "../../../components/Section/Section" +import { TokensPageView } from "./TokensPageView" +import { tokensMachine } from "xServices/tokens/tokensXService" +import { useMachine } from "@xstate/react" +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { Typography } from "components/Typography/Typography" +import makeStyles from "@material-ui/core/styles/makeStyles" + +export const Language = { + title: "Tokens", + descriptionPrefix: + "Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ", + deleteTitle: "Delete Token", + deleteDescription: "Are you sure you want to delete this token?", +} + +export const TokensPage: FC> = () => { + const [tokensState, tokensSend] = useMachine(tokensMachine) + const isLoading = tokensState.matches("gettingTokens") + const hasLoaded = tokensState.matches("loaded") + const { getTokensError, tokens, deleteTokenId } = tokensState.context + const styles = useStyles() + const description = ( +

+ {Language.descriptionPrefix}{" "} + coder tokens create command. +

+ ) + + const content = ( + + {Language.deleteDescription} +
+
+ {deleteTokenId} +
+ ) + + return ( + <> +
+ { + tokensSend({ type: "DELETE_TOKEN", id }) + }} + /> +
+ + { + tokensSend("CONFIRM_DELETE_TOKEN") + }} + onClose={() => { + tokensSend("CANCEL_DELETE_TOKEN") + }} + /> + + ) +} + +const useStyles = makeStyles((theme) => ({ + code: { + background: theme.palette.divider, + fontSize: 12, + padding: "2px 4px", + color: theme.palette.text.primary, + borderRadius: 2, + }, +})) + +export default TokensPage diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.stories.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.stories.tsx new file mode 100644 index 0000000000000..9c88c34a34270 --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.stories.tsx @@ -0,0 +1,56 @@ +import { Story } from "@storybook/react" +import { makeMockApiError, MockTokens } from "testHelpers/entities" +import { TokensPageView, TokensPageViewProps } from "./TokensPageView" + +export default { + title: "components/TokensPageView", + component: TokensPageView, + argTypes: { + onRegenerateClick: { action: "Submit" }, + }, +} + +const Template: Story = (args: TokensPageViewProps) => ( + +) + +export const Example = Template.bind({}) +Example.args = { + isLoading: false, + hasLoaded: true, + tokens: MockTokens, + onDelete: () => { + return Promise.resolve() + }, +} + +export const Loading = Template.bind({}) +Loading.args = { + ...Example.args, + isLoading: true, + hasLoaded: false, +} + +export const Empty = Template.bind({}) +Empty.args = { + ...Example.args, + tokens: [], +} + +export const WithGetTokensError = Template.bind({}) +WithGetTokensError.args = { + ...Example.args, + hasLoaded: false, + getTokensError: makeMockApiError({ + message: "Failed to get tokens.", + }), +} + +export const WithDeleteTokenError = Template.bind({}) +WithDeleteTokenError.args = { + ...Example.args, + hasLoaded: false, + deleteTokenError: makeMockApiError({ + message: "Failed to delete token.", + }), +} diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx new file mode 100644 index 0000000000000..3b965cb43ff09 --- /dev/null +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx @@ -0,0 +1,132 @@ +import { useTheme } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import { APIKey } from "api/typesGenerated" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { Stack } from "components/Stack/Stack" +import { TableEmpty } from "components/TableEmpty/TableEmpty" +import { TableLoader } from "components/TableLoader/TableLoader" +import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" +import dayjs from "dayjs" +import { FC } from "react" +import { AlertBanner } from "components/AlertBanner/AlertBanner" +import IconButton from "@material-ui/core/IconButton/IconButton" + +export const Language = { + idLabel: "ID", + createdAtLabel: "Created At", + lastUsedLabel: "Last Used", + expiresAtLabel: "Expires At", + emptyMessage: "No tokens found", + ariaDeleteLabel: "Delete Token", +} + +const lastUsedOrNever = (lastUsed: string) => { + const t = dayjs(lastUsed) + const now = dayjs() + return now.isBefore(t.add(100, "year")) ? t.fromNow() : "Never" +} + +export interface TokensPageViewProps { + tokens?: APIKey[] + getTokensError?: Error | unknown + isLoading: boolean + hasLoaded: boolean + onDelete: (id: string) => void + deleteTokenError?: Error | unknown +} + +export const TokensPageView: FC< + React.PropsWithChildren +> = ({ + tokens, + getTokensError, + isLoading, + hasLoaded, + onDelete, + deleteTokenError, +}) => { + const theme = useTheme() + + return ( + + {Boolean(getTokensError) && ( + + )} + {Boolean(deleteTokenError) && ( + + )} + + + + + {Language.idLabel} + {Language.createdAtLabel} + {Language.lastUsedLabel} + {Language.expiresAtLabel} + + + + + + + + + + + + + {tokens?.map((token) => { + return ( + + + + {token.id} + + + + + + {dayjs(token.created_at).fromNow()} + + + + {lastUsedOrNever(token.last_used)} + + + + {dayjs(token.expires_at).fromNow()} + + + + + { + onDelete(token.id) + }} + size="medium" + aria-label={Language.ariaDeleteLabel} + > + + + + + + ) + })} + + + +
+
+
+ ) +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 5dc194dc24f03..ffcd3ea8b8b35 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -20,6 +20,31 @@ export const MockAPIKey: TypesGen.GenerateAPIKeyResponse = { key: "my-api-key", } +export const MockTokens: TypesGen.APIKey[] = [ + { + id: "tBoVE3dqLl", + user_id: "f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b", + last_used: "0001-01-01T00:00:00Z", + expires_at: "2023-01-15T20:10:45.637438Z", + created_at: "2022-12-16T20:10:45.637452Z", + updated_at: "2022-12-16T20:10:45.637452Z", + login_type: "token", + scope: "all", + lifetime_seconds: 2592000, + }, + { + id: "tBoVE3dqLl", + user_id: "f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b", + last_used: "0001-01-01T00:00:00Z", + expires_at: "2023-01-15T20:10:45.637438Z", + created_at: "2022-12-16T20:10:45.637452Z", + updated_at: "2022-12-16T20:10:45.637452Z", + login_type: "token", + scope: "all", + lifetime_seconds: 2592000, + }, +] + export const MockBuildInfo: TypesGen.BuildInfoResponse = { external_url: "file:///mock-url", version: "v99.999.9999+c9cdf14", diff --git a/site/src/xServices/tokens/tokensXService.ts b/site/src/xServices/tokens/tokensXService.ts new file mode 100644 index 0000000000000..1bea3af5b9c94 --- /dev/null +++ b/site/src/xServices/tokens/tokensXService.ts @@ -0,0 +1,139 @@ +import { getTokens, deleteAPIKey } from "api/api" +import { APIKey } from "api/typesGenerated" +import { displaySuccess } from "components/GlobalSnackbar/utils" +import { createMachine, assign } from "xstate" + +interface Context { + tokens?: APIKey[] + getTokensError?: unknown + deleteTokenError?: unknown + deleteTokenId?: string +} + +type Events = + | { type: "DELETE_TOKEN"; id: string } + | { type: "CONFIRM_DELETE_TOKEN" } + | { type: "CANCEL_DELETE_TOKEN" } + +const Language = { + deleteSuccess: "Token has been deleted", +} + +export const tokensMachine = createMachine( + { + id: "tokensState", + predictableActionArguments: true, + schema: { + context: {} as Context, + events: {} as Events, + services: {} as { + getTokens: { + data: APIKey[] + } + deleteToken: { + data: unknown + } + }, + }, + tsTypes: {} as import("./tokensXService.typegen").Typegen0, + initial: "gettingTokens", + states: { + gettingTokens: { + entry: "clearGetTokensError", + invoke: { + src: "getTokens", + onDone: [ + { + actions: "assignTokens", + target: "loaded", + }, + ], + onError: [ + { + actions: "assignGetTokensError", + target: "notLoaded", + }, + ], + }, + }, + notLoaded: { + type: "final", + }, + loaded: { + on: { + DELETE_TOKEN: { + actions: "assignDeleteTokenId", + target: "confirmTokenDelete", + }, + }, + }, + confirmTokenDelete: { + on: { + CANCEL_DELETE_TOKEN: { + actions: "clearDeleteTokenId", + target: "loaded", + }, + CONFIRM_DELETE_TOKEN: { + target: "deletingToken", + }, + }, + }, + deletingToken: { + entry: "clearDeleteTokenError", + invoke: { + src: "deleteToken", + onDone: [ + { + actions: ["clearDeleteTokenId", "notifySuccessTokenDeleted"], + target: "gettingTokens", + }, + ], + onError: [ + { + actions: ["clearDeleteTokenId", "assignDeleteTokenError"], + target: "loaded", + }, + ], + }, + }, + }, + }, + { + services: { + getTokens: () => getTokens(), + deleteToken: (context) => { + if (context.deleteTokenId === undefined) { + return Promise.reject("No token id to delete") + } + + return deleteAPIKey(context.deleteTokenId) + }, + }, + actions: { + assignTokens: assign({ + tokens: (_, { data }) => data, + }), + assignGetTokensError: assign({ + getTokensError: (_, { data }) => data, + }), + clearGetTokensError: assign({ + getTokensError: (_) => undefined, + }), + assignDeleteTokenId: assign({ + deleteTokenId: (_, event) => event.id, + }), + clearDeleteTokenId: assign({ + deleteTokenId: (_) => undefined, + }), + assignDeleteTokenError: assign({ + deleteTokenError: (_, { data }) => data, + }), + clearDeleteTokenError: assign({ + deleteTokenError: (_) => undefined, + }), + notifySuccessTokenDeleted: () => { + displaySuccess(Language.deleteSuccess) + }, + }, + }, +)