feat: Manage tokens in dashboard (#5444)

This commit is contained in:
Garrett Delfosse
2023-01-13 12:20:03 -05:00
committed by GitHub
parent f76ef98a32
commit 0cf713869b
9 changed files with 457 additions and 6 deletions

View File

@ -189,10 +189,6 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
} }
keys, err := api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken) 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 { if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching API keys.", Message: "Internal error fetching API keys.",
@ -201,7 +197,7 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
return return
} }
var apiKeys []codersdk.APIKey apiKeys := []codersdk.APIKey{}
for _, key := range keys { for _, key := range keys {
apiKeys = append(apiKeys, convertAPIKey(key)) apiKeys = append(apiKeys, convertAPIKey(key))
} }

View File

@ -39,6 +39,9 @@ const SecurityPage = lazy(
const SSHKeysPage = lazy( const SSHKeysPage = lazy(
() => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"), () => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"),
) )
const TokensPage = lazy(
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
)
const CreateUserPage = lazy( const CreateUserPage = lazy(
() => import("./pages/UsersPage/CreateUserPage/CreateUserPage"), () => import("./pages/UsersPage/CreateUserPage/CreateUserPage"),
) )
@ -219,6 +222,7 @@ export const AppRouter: FC = () => {
<Route path="account" element={<AccountPage />} /> <Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} /> <Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} /> <Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tokens" element={<TokensPage />} />
</Route> </Route>
<Route path="/@:username"> <Route path="/@:username">

View File

@ -133,6 +133,18 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
return response.data return response.data
} }
export const getTokens = async (): Promise<TypesGen.APIKey[]> => {
const response = await axios.get<TypesGen.APIKey[]>(
"/api/v2/users/me/keys/tokens",
)
return response.data
}
export const deleteAPIKey = async (keyId: string): Promise<void> => {
const response = await axios.delete("/api/v2/users/me/keys/" + keyId)
return response.data
}
export const getUsers = async ( export const getUsers = async (
options: TypesGen.UsersRequest, options: TypesGen.UsersRequest,
): Promise<TypesGen.GetUsersResponse> => { ): Promise<TypesGen.GetUsersResponse> => {

View File

@ -1,5 +1,6 @@
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined" import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
import FingerprintOutlinedIcon from "@material-ui/icons/FingerprintOutlined"
import { User } from "api/typesGenerated" import { User } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { UserAvatar } from "components/UserAvatar/UserAvatar" import { UserAvatar } from "components/UserAvatar/UserAvatar"
@ -65,10 +66,16 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
</SidebarNavItem> </SidebarNavItem>
<SidebarNavItem <SidebarNavItem
href="ssh-keys" href="ssh-keys"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />} icon={<SidebarNavItemIcon icon={FingerprintOutlinedIcon} />}
> >
SSH Keys SSH Keys
</SidebarNavItem> </SidebarNavItem>
<SidebarNavItem
href="tokens"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
>
Tokens
</SidebarNavItem>
</nav> </nav>
) )
} }

View File

@ -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<PropsWithChildren<unknown>> = () => {
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 = (
<p>
{Language.descriptionPrefix}{" "}
<code className={styles.code}>coder tokens create</code> command.
</p>
)
const content = (
<Typography>
{Language.deleteDescription}
<br />
<br />
{deleteTokenId}
</Typography>
)
return (
<>
<Section title={Language.title} description={description} layout="fluid">
<TokensPageView
tokens={tokens}
isLoading={isLoading}
hasLoaded={hasLoaded}
getTokensError={getTokensError}
onDelete={(id) => {
tokensSend({ type: "DELETE_TOKEN", id })
}}
/>
</Section>
<ConfirmDialog
title={Language.deleteTitle}
description={content}
open={tokensState.matches("confirmTokenDelete")}
confirmLoading={tokensState.matches("deletingToken")}
onConfirm={() => {
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

View File

@ -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<TokensPageViewProps> = (args: TokensPageViewProps) => (
<TokensPageView {...args} />
)
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.",
}),
}

View File

@ -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<TokensPageViewProps>
> = ({
tokens,
getTokensError,
isLoading,
hasLoaded,
onDelete,
deleteTokenError,
}) => {
const theme = useTheme()
return (
<Stack>
{Boolean(getTokensError) && (
<AlertBanner severity="error" error={getTokensError} />
)}
{Boolean(deleteTokenError) && (
<AlertBanner severity="error" error={deleteTokenError} />
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="25%">{Language.idLabel}</TableCell>
<TableCell width="25%">{Language.createdAtLabel}</TableCell>
<TableCell width="25%">{Language.lastUsedLabel}</TableCell>
<TableCell width="25%">{Language.expiresAtLabel}</TableCell>
<TableCell width="0%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={hasLoaded && tokens?.length === 0}>
<TableEmpty message={Language.emptyMessage} />
</Cond>
<Cond>
{tokens?.map((token) => {
return (
<TableRow
key={token.id}
data-testid={`token-${token.id}`}
tabIndex={0}
>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.id}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{dayjs(token.created_at).fromNow()}
</span>
</TableCell>
<TableCell>{lastUsedOrNever(token.last_used)}</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{dayjs(token.expires_at).fromNow()}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
<IconButton
onClick={() => {
onDelete(token.id)
}}
size="medium"
aria-label={Language.ariaDeleteLabel}
>
<DeleteOutlineIcon />
</IconButton>
</span>
</TableCell>
</TableRow>
)
})}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Stack>
)
}

View File

@ -20,6 +20,31 @@ export const MockAPIKey: TypesGen.GenerateAPIKeyResponse = {
key: "my-api-key", 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 = { export const MockBuildInfo: TypesGen.BuildInfoResponse = {
external_url: "file:///mock-url", external_url: "file:///mock-url",
version: "v99.999.9999+c9cdf14", version: "v99.999.9999+c9cdf14",

View File

@ -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)
},
},
},
)