> = () => {
+ 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)
+ },
+ },
+ },
+)