Skip to content

Commit 0cf7138

Browse files
authored
feat: Manage tokens in dashboard (#5444)
1 parent f76ef98 commit 0cf7138

File tree

9 files changed

+457
-6
lines changed

9 files changed

+457
-6
lines changed

coderd/apikey.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,6 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
189189
}
190190

191191
keys, err := api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken)
192-
if errors.Is(err, sql.ErrNoRows) {
193-
httpapi.Write(ctx, rw, http.StatusOK, []codersdk.APIKey{})
194-
return
195-
}
196192
if err != nil {
197193
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
198194
Message: "Internal error fetching API keys.",
@@ -201,7 +197,7 @@ func (api *API) tokens(rw http.ResponseWriter, r *http.Request) {
201197
return
202198
}
203199

204-
var apiKeys []codersdk.APIKey
200+
apiKeys := []codersdk.APIKey{}
205201
for _, key := range keys {
206202
apiKeys = append(apiKeys, convertAPIKey(key))
207203
}

site/src/AppRouter.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const SecurityPage = lazy(
3939
const SSHKeysPage = lazy(
4040
() => import("./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"),
4141
)
42+
const TokensPage = lazy(
43+
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
44+
)
4245
const CreateUserPage = lazy(
4346
() => import("./pages/UsersPage/CreateUserPage/CreateUserPage"),
4447
)
@@ -219,6 +222,7 @@ export const AppRouter: FC = () => {
219222
<Route path="account" element={<AccountPage />} />
220223
<Route path="security" element={<SecurityPage />} />
221224
<Route path="ssh-keys" element={<SSHKeysPage />} />
225+
<Route path="tokens" element={<TokensPage />} />
222226
</Route>
223227

224228
<Route path="/@:username">

site/src/api/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
133133
return response.data
134134
}
135135

136+
export const getTokens = async (): Promise<TypesGen.APIKey[]> => {
137+
const response = await axios.get<TypesGen.APIKey[]>(
138+
"/api/v2/users/me/keys/tokens",
139+
)
140+
return response.data
141+
}
142+
143+
export const deleteAPIKey = async (keyId: string): Promise<void> => {
144+
const response = await axios.delete("/api/v2/users/me/keys/" + keyId)
145+
return response.data
146+
}
147+
136148
export const getUsers = async (
137149
options: TypesGen.UsersRequest,
138150
): Promise<TypesGen.GetUsersResponse> => {

site/src/components/SettingsLayout/Sidebar.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { makeStyles } from "@material-ui/core/styles"
22
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
3+
import FingerprintOutlinedIcon from "@material-ui/icons/FingerprintOutlined"
34
import { User } from "api/typesGenerated"
45
import { Stack } from "components/Stack/Stack"
56
import { UserAvatar } from "components/UserAvatar/UserAvatar"
@@ -65,10 +66,16 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
6566
</SidebarNavItem>
6667
<SidebarNavItem
6768
href="ssh-keys"
68-
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
69+
icon={<SidebarNavItemIcon icon={FingerprintOutlinedIcon} />}
6970
>
7071
SSH Keys
7172
</SidebarNavItem>
73+
<SidebarNavItem
74+
href="tokens"
75+
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
76+
>
77+
Tokens
78+
</SidebarNavItem>
7279
</nav>
7380
)
7481
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { FC, PropsWithChildren } from "react"
2+
import { Section } from "../../../components/Section/Section"
3+
import { TokensPageView } from "./TokensPageView"
4+
import { tokensMachine } from "xServices/tokens/tokensXService"
5+
import { useMachine } from "@xstate/react"
6+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
7+
import { Typography } from "components/Typography/Typography"
8+
import makeStyles from "@material-ui/core/styles/makeStyles"
9+
10+
export const Language = {
11+
title: "Tokens",
12+
descriptionPrefix:
13+
"Tokens are used to authenticate with the Coder API. You can create a token with the Coder CLI using the ",
14+
deleteTitle: "Delete Token",
15+
deleteDescription: "Are you sure you want to delete this token?",
16+
}
17+
18+
export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
19+
const [tokensState, tokensSend] = useMachine(tokensMachine)
20+
const isLoading = tokensState.matches("gettingTokens")
21+
const hasLoaded = tokensState.matches("loaded")
22+
const { getTokensError, tokens, deleteTokenId } = tokensState.context
23+
const styles = useStyles()
24+
const description = (
25+
<p>
26+
{Language.descriptionPrefix}{" "}
27+
<code className={styles.code}>coder tokens create</code> command.
28+
</p>
29+
)
30+
31+
const content = (
32+
<Typography>
33+
{Language.deleteDescription}
34+
<br />
35+
<br />
36+
{deleteTokenId}
37+
</Typography>
38+
)
39+
40+
return (
41+
<>
42+
<Section title={Language.title} description={description} layout="fluid">
43+
<TokensPageView
44+
tokens={tokens}
45+
isLoading={isLoading}
46+
hasLoaded={hasLoaded}
47+
getTokensError={getTokensError}
48+
onDelete={(id) => {
49+
tokensSend({ type: "DELETE_TOKEN", id })
50+
}}
51+
/>
52+
</Section>
53+
54+
<ConfirmDialog
55+
title={Language.deleteTitle}
56+
description={content}
57+
open={tokensState.matches("confirmTokenDelete")}
58+
confirmLoading={tokensState.matches("deletingToken")}
59+
onConfirm={() => {
60+
tokensSend("CONFIRM_DELETE_TOKEN")
61+
}}
62+
onClose={() => {
63+
tokensSend("CANCEL_DELETE_TOKEN")
64+
}}
65+
/>
66+
</>
67+
)
68+
}
69+
70+
const useStyles = makeStyles((theme) => ({
71+
code: {
72+
background: theme.palette.divider,
73+
fontSize: 12,
74+
padding: "2px 4px",
75+
color: theme.palette.text.primary,
76+
borderRadius: 2,
77+
},
78+
}))
79+
80+
export default TokensPage
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Story } from "@storybook/react"
2+
import { makeMockApiError, MockTokens } from "testHelpers/entities"
3+
import { TokensPageView, TokensPageViewProps } from "./TokensPageView"
4+
5+
export default {
6+
title: "components/TokensPageView",
7+
component: TokensPageView,
8+
argTypes: {
9+
onRegenerateClick: { action: "Submit" },
10+
},
11+
}
12+
13+
const Template: Story<TokensPageViewProps> = (args: TokensPageViewProps) => (
14+
<TokensPageView {...args} />
15+
)
16+
17+
export const Example = Template.bind({})
18+
Example.args = {
19+
isLoading: false,
20+
hasLoaded: true,
21+
tokens: MockTokens,
22+
onDelete: () => {
23+
return Promise.resolve()
24+
},
25+
}
26+
27+
export const Loading = Template.bind({})
28+
Loading.args = {
29+
...Example.args,
30+
isLoading: true,
31+
hasLoaded: false,
32+
}
33+
34+
export const Empty = Template.bind({})
35+
Empty.args = {
36+
...Example.args,
37+
tokens: [],
38+
}
39+
40+
export const WithGetTokensError = Template.bind({})
41+
WithGetTokensError.args = {
42+
...Example.args,
43+
hasLoaded: false,
44+
getTokensError: makeMockApiError({
45+
message: "Failed to get tokens.",
46+
}),
47+
}
48+
49+
export const WithDeleteTokenError = Template.bind({})
50+
WithDeleteTokenError.args = {
51+
...Example.args,
52+
hasLoaded: false,
53+
deleteTokenError: makeMockApiError({
54+
message: "Failed to delete token.",
55+
}),
56+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useTheme } from "@material-ui/core/styles"
2+
import Table from "@material-ui/core/Table"
3+
import TableBody from "@material-ui/core/TableBody"
4+
import TableCell from "@material-ui/core/TableCell"
5+
import TableContainer from "@material-ui/core/TableContainer"
6+
import TableHead from "@material-ui/core/TableHead"
7+
import TableRow from "@material-ui/core/TableRow"
8+
import { APIKey } from "api/typesGenerated"
9+
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
10+
import { Stack } from "components/Stack/Stack"
11+
import { TableEmpty } from "components/TableEmpty/TableEmpty"
12+
import { TableLoader } from "components/TableLoader/TableLoader"
13+
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
14+
import dayjs from "dayjs"
15+
import { FC } from "react"
16+
import { AlertBanner } from "components/AlertBanner/AlertBanner"
17+
import IconButton from "@material-ui/core/IconButton/IconButton"
18+
19+
export const Language = {
20+
idLabel: "ID",
21+
createdAtLabel: "Created At",
22+
lastUsedLabel: "Last Used",
23+
expiresAtLabel: "Expires At",
24+
emptyMessage: "No tokens found",
25+
ariaDeleteLabel: "Delete Token",
26+
}
27+
28+
const lastUsedOrNever = (lastUsed: string) => {
29+
const t = dayjs(lastUsed)
30+
const now = dayjs()
31+
return now.isBefore(t.add(100, "year")) ? t.fromNow() : "Never"
32+
}
33+
34+
export interface TokensPageViewProps {
35+
tokens?: APIKey[]
36+
getTokensError?: Error | unknown
37+
isLoading: boolean
38+
hasLoaded: boolean
39+
onDelete: (id: string) => void
40+
deleteTokenError?: Error | unknown
41+
}
42+
43+
export const TokensPageView: FC<
44+
React.PropsWithChildren<TokensPageViewProps>
45+
> = ({
46+
tokens,
47+
getTokensError,
48+
isLoading,
49+
hasLoaded,
50+
onDelete,
51+
deleteTokenError,
52+
}) => {
53+
const theme = useTheme()
54+
55+
return (
56+
<Stack>
57+
{Boolean(getTokensError) && (
58+
<AlertBanner severity="error" error={getTokensError} />
59+
)}
60+
{Boolean(deleteTokenError) && (
61+
<AlertBanner severity="error" error={deleteTokenError} />
62+
)}
63+
<TableContainer>
64+
<Table>
65+
<TableHead>
66+
<TableRow>
67+
<TableCell width="25%">{Language.idLabel}</TableCell>
68+
<TableCell width="25%">{Language.createdAtLabel}</TableCell>
69+
<TableCell width="25%">{Language.lastUsedLabel}</TableCell>
70+
<TableCell width="25%">{Language.expiresAtLabel}</TableCell>
71+
<TableCell width="0%"></TableCell>
72+
</TableRow>
73+
</TableHead>
74+
<TableBody>
75+
<ChooseOne>
76+
<Cond condition={isLoading}>
77+
<TableLoader />
78+
</Cond>
79+
<Cond condition={hasLoaded && tokens?.length === 0}>
80+
<TableEmpty message={Language.emptyMessage} />
81+
</Cond>
82+
<Cond>
83+
{tokens?.map((token) => {
84+
return (
85+
<TableRow
86+
key={token.id}
87+
data-testid={`token-${token.id}`}
88+
tabIndex={0}
89+
>
90+
<TableCell>
91+
<span style={{ color: theme.palette.text.secondary }}>
92+
{token.id}
93+
</span>
94+
</TableCell>
95+
96+
<TableCell>
97+
<span style={{ color: theme.palette.text.secondary }}>
98+
{dayjs(token.created_at).fromNow()}
99+
</span>
100+
</TableCell>
101+
102+
<TableCell>{lastUsedOrNever(token.last_used)}</TableCell>
103+
104+
<TableCell>
105+
<span style={{ color: theme.palette.text.secondary }}>
106+
{dayjs(token.expires_at).fromNow()}
107+
</span>
108+
</TableCell>
109+
<TableCell>
110+
<span style={{ color: theme.palette.text.secondary }}>
111+
<IconButton
112+
onClick={() => {
113+
onDelete(token.id)
114+
}}
115+
size="medium"
116+
aria-label={Language.ariaDeleteLabel}
117+
>
118+
<DeleteOutlineIcon />
119+
</IconButton>
120+
</span>
121+
</TableCell>
122+
</TableRow>
123+
)
124+
})}
125+
</Cond>
126+
</ChooseOne>
127+
</TableBody>
128+
</Table>
129+
</TableContainer>
130+
</Stack>
131+
)
132+
}

site/src/testHelpers/entities.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,31 @@ export const MockAPIKey: TypesGen.GenerateAPIKeyResponse = {
2020
key: "my-api-key",
2121
}
2222

23+
export const MockTokens: TypesGen.APIKey[] = [
24+
{
25+
id: "tBoVE3dqLl",
26+
user_id: "f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b",
27+
last_used: "0001-01-01T00:00:00Z",
28+
expires_at: "2023-01-15T20:10:45.637438Z",
29+
created_at: "2022-12-16T20:10:45.637452Z",
30+
updated_at: "2022-12-16T20:10:45.637452Z",
31+
login_type: "token",
32+
scope: "all",
33+
lifetime_seconds: 2592000,
34+
},
35+
{
36+
id: "tBoVE3dqLl",
37+
user_id: "f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b",
38+
last_used: "0001-01-01T00:00:00Z",
39+
expires_at: "2023-01-15T20:10:45.637438Z",
40+
created_at: "2022-12-16T20:10:45.637452Z",
41+
updated_at: "2022-12-16T20:10:45.637452Z",
42+
login_type: "token",
43+
scope: "all",
44+
lifetime_seconds: 2592000,
45+
},
46+
]
47+
2348
export const MockBuildInfo: TypesGen.BuildInfoResponse = {
2449
external_url: "file:///mock-url",
2550
version: "v99.999.9999+c9cdf14",

0 commit comments

Comments
 (0)