mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
feat: Manage tokens in dashboard (#5444)
This commit is contained in:
@ -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))
|
||||
}
|
||||
|
@ -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 = () => {
|
||||
<Route path="account" element={<AccountPage />} />
|
||||
<Route path="security" element={<SecurityPage />} />
|
||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||
<Route path="tokens" element={<TokensPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/@:username">
|
||||
|
@ -133,6 +133,18 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
|
||||
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 (
|
||||
options: TypesGen.UsersRequest,
|
||||
): Promise<TypesGen.GetUsersResponse> => {
|
||||
|
@ -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 }) => {
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="ssh-keys"
|
||||
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
|
||||
icon={<SidebarNavItemIcon icon={FingerprintOutlinedIcon} />}
|
||||
>
|
||||
SSH Keys
|
||||
</SidebarNavItem>
|
||||
<SidebarNavItem
|
||||
href="tokens"
|
||||
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
|
||||
>
|
||||
Tokens
|
||||
</SidebarNavItem>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
80
site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx
Normal file
80
site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx
Normal 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
|
@ -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.",
|
||||
}),
|
||||
}
|
132
site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
Normal file
132
site/src/pages/UserSettingsPage/TokensPage/TokensPageView.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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",
|
||||
|
139
site/src/xServices/tokens/tokensXService.ts
Normal file
139
site/src/xServices/tokens/tokensXService.ts
Normal 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)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
Reference in New Issue
Block a user