feat: Add SSH Keys page on /preferences/ssh-keys (#1478)

This commit is contained in:
Bruno Quaresma
2022-05-16 12:15:45 -05:00
committed by GitHub
parent 5447c4a3cf
commit abbe548d5c
7 changed files with 284 additions and 16 deletions
+10
View File
@@ -206,3 +206,13 @@ export const updateUserRoles = async (
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles })
return response.data
}
export const getUserSSHKey = async (userId = "me"): Promise<TypesGen.GitSSHKey> => {
const response = await axios.get<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`)
return response.data
}
export const regenerateUserSSHKey = async (userId = "me"): Promise<TypesGen.GitSSHKey> => {
const response = await axios.put<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`)
return response.data
}
@@ -1,12 +0,0 @@
import React from "react"
import { Section } from "../../../components/Section/Section"
const Language = {
title: "SSH Keys",
description:
"Coder automatically inserts a private key into every workspace; you can add the corresponding public key to any services (such as Git) that you need access to from your workspace.",
}
export const SSHKeysPage: React.FC = () => {
return <Section title={Language.title} description={Language.description} />
}
@@ -0,0 +1,87 @@
import { fireEvent, screen, within } from "@testing-library/react"
import React from "react"
import * as API from "../../../api/api"
import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar"
import { MockGitSSHKey, renderWithAuth } from "../../../testHelpers/renderHelpers"
import { Language as authXServiceLanguage } from "../../../xServices/auth/authXService"
import { Language as SSHKeysPageLanguage, SSHKeysPage } from "./SSHKeysPage"
describe("SSH Keys Page", () => {
it("shows the SSH key", async () => {
renderWithAuth(<SSHKeysPage />)
await screen.findByText(MockGitSSHKey.public_key)
})
describe("regenerate SSH key", () => {
describe("when it is success", () => {
it("shows a success message and updates the ssh key on the page", async () => {
renderWithAuth(
<>
<SSHKeysPage />
<GlobalSnackbar />
</>,
)
// Wait to the ssh be rendered on the screen
await screen.findByText(MockGitSSHKey.public_key)
// Click on the "Regenerate" button to display the confirm dialog
const regenerateButton = screen.getByRole("button", { name: SSHKeysPageLanguage.regenerateLabel })
fireEvent.click(regenerateButton)
const confirmDialog = screen.getByRole("dialog")
expect(confirmDialog).toHaveTextContent(SSHKeysPageLanguage.regenerateDialogMessage)
const newUserSSHKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDSC/ouD/LqiT1Rd99vDv/MwUmqzJuinLTMTpk5kVy66"
jest.spyOn(API, "regenerateUserSSHKey").mockResolvedValueOnce({
...MockGitSSHKey,
public_key: newUserSSHKey,
})
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByRole("button", { name: SSHKeysPageLanguage.confirmLabel })
fireEvent.click(confirmButton)
// Check if the success message is displayed
await screen.findByText(authXServiceLanguage.successRegenerateSSHKey)
// Check if the API was called correctly
expect(API.regenerateUserSSHKey).toBeCalledTimes(1)
// Check if the SSH key is updated
await screen.findByText(newUserSSHKey)
})
})
describe("when it fails", () => {
it("shows an error message", async () => {
renderWithAuth(
<>
<SSHKeysPage />
<GlobalSnackbar />
</>,
)
// Wait to the ssh be rendered on the screen
await screen.findByText(MockGitSSHKey.public_key)
jest.spyOn(API, "regenerateUserSSHKey").mockRejectedValueOnce({})
// Click on the "Regenerate" button to display the confirm dialog
const regenerateButton = screen.getByRole("button", { name: SSHKeysPageLanguage.regenerateLabel })
fireEvent.click(regenerateButton)
const confirmDialog = screen.getByRole("dialog")
expect(confirmDialog).toHaveTextContent(SSHKeysPageLanguage.regenerateDialogMessage)
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByRole("button", { name: SSHKeysPageLanguage.confirmLabel })
fireEvent.click(confirmButton)
// Check if the error message is displayed
await screen.findByText(authXServiceLanguage.errorRegenerateSSHKey)
// Check if the API was called correctly
expect(API.regenerateUserSSHKey).toBeCalledTimes(1)
})
})
})
})
@@ -1,12 +1,76 @@
import React from "react"
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
import { useActor } from "@xstate/react"
import React, { useContext, useEffect } from "react"
import { CodeBlock } from "../../../components/CodeBlock/CodeBlock"
import { ConfirmDialog } from "../../../components/ConfirmDialog/ConfirmDialog"
import { Section } from "../../../components/Section/Section"
import { Stack } from "../../../components/Stack/Stack"
import { XServiceContext } from "../../../xServices/StateContext"
const Language = {
export const Language = {
title: "SSH Keys",
description:
"Coder automatically inserts a private key into every workspace; you can add the corresponding public key to any services (such as Git) that you need access to from your workspace.",
regenerateLabel: "Regenerate",
regenerateDialogTitle: "Regenerate SSH Key?",
regenerateDialogMessage:
"You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
confirmLabel: "Confirm",
cancelLabel: "Cancel",
}
export const SSHKeysPage: React.FC = () => {
return <Section title={Language.title} description={Language.description} />
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const { sshKey } = authState.context
useEffect(() => {
authSend({ type: "GET_SSH_KEY" })
}, [authSend])
return (
<>
<Section title={Language.title} description={Language.description}>
{!sshKey && (
<Box p={4}>
<CircularProgress size={26} />
</Box>
)}
{sshKey && (
<Stack>
<CodeBlock lines={[sshKey.public_key.trim()]} />
<div>
<Button
color="primary"
onClick={() => {
authSend({ type: "REGENERATE_SSH_KEY" })
}}
>
{Language.regenerateLabel}
</Button>
</div>
</Stack>
)}
</Section>
<ConfirmDialog
type="delete"
hideCancel={false}
open={authState.matches("signedIn.ssh.loaded.confirmSSHKeyRegenerate")}
confirmLoading={authState.matches("signedIn.ssh.loaded.regeneratingSSHKey")}
title={Language.regenerateDialogTitle}
confirmText={Language.confirmLabel}
onConfirm={() => {
authSend({ type: "CONFIRM_REGENERATE_SSH_KEY" })
}}
onClose={() => {
authSend({ type: "CANCEL_REGENERATE_SSH_KEY" })
}}
description={<>{Language.regenerateDialogMessage}</>}
/>
</>
)
}
+7
View File
@@ -205,3 +205,10 @@ export const MockAuthMethods: TypesGen.AuthMethods = {
password: true,
github: false,
}
export const MockGitSSHKey: TypesGen.GitSSHKey = {
user_id: "1fa0200f-7331-4524-a364-35770666caa7",
created_at: "2022-05-16T14:30:34.148205897Z",
updated_at: "2022-05-16T15:29:10.302441433Z",
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJOQRIM7kE30rOzrfy+/+R+nQGCk7S9pioihy+2ARbq",
}
+3
View File
@@ -68,6 +68,9 @@ export const handlers = [
return res(ctx.status(200), ctx.json(response))
}),
rest.get("/api/v2/users/:userId/gitsshkey", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockGitSSHKey))
}),
// workspaces
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {
+110 -1
View File
@@ -1,10 +1,12 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
successProfileUpdate: "Updated preferences.",
successRegenerateSSHKey: "SSH Key regenerated successfully",
errorRegenerateSSHKey: "Error on regenerate the SSH Key",
}
export const checks = {
@@ -31,12 +33,87 @@ export interface AuthContext {
methods?: TypesGen.AuthMethods
permissions?: Permissions
checkPermissionsError?: Error | unknown
// SSH
sshKey?: TypesGen.GitSSHKey
getSSHKeyError?: Error | unknown
regenerateSSHKeyError?: Error | unknown
}
export type AuthEvent =
| { type: "SIGN_OUT" }
| { type: "SIGN_IN"; email: string; password: string }
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
| { type: "GET_SSH_KEY" }
| { type: "REGENERATE_SSH_KEY" }
| { type: "CONFIRM_REGENERATE_SSH_KEY" }
| { type: "CANCEL_REGENERATE_SSH_KEY" }
const sshState = {
initial: "idle",
states: {
idle: {
on: {
GET_SSH_KEY: {
target: "gettingSSHKey",
},
},
},
gettingSSHKey: {
entry: "clearGetSSHKeyError",
invoke: {
src: "getSSHKey",
onDone: [
{
actions: ["assignSSHKey"],
target: "#authState.signedIn.ssh.loaded",
},
],
onError: [
{
actions: "assignGetSSHKeyError",
target: "#authState.signedIn.ssh.idle",
},
],
},
},
loaded: {
initial: "idle",
states: {
idle: {
on: {
REGENERATE_SSH_KEY: {
target: "confirmSSHKeyRegenerate",
},
},
},
confirmSSHKeyRegenerate: {
on: {
CANCEL_REGENERATE_SSH_KEY: "idle",
CONFIRM_REGENERATE_SSH_KEY: "regeneratingSSHKey",
},
},
regeneratingSSHKey: {
entry: "clearRegenerateSSHKeyError",
invoke: {
src: "regenerateSSHKey",
onDone: [
{
actions: ["assignSSHKey", "notifySuccessSSHKeyRegenerated"],
target: "#authState.signedIn.ssh.loaded.idle",
},
],
onError: [
{
actions: ["assignRegenerateSSHKeyError", "notifySSHKeyRegenerationError"],
target: "#authState.signedIn.ssh.loaded.idle",
},
],
},
},
},
},
},
}
export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogDsABgCshOQA4AzABY5ARgBMUgJxrtcgDQgAnok0zNSwkpkrNKvQDY3aqS6kBfb6bRYuPhEpBQk5FAM5LQQIkThAG58ANYhZORRYvyCwqJIEohyMlKE2mrFKo7aLq5SJuaILtq2mjZqrboy2s4+fiABOHgExOnhkdFgAE6TfJOEPAA2+ABmswC2IxSZ+dkkQiJikgiyCsrqWroGRqYWCHoq2oQyDpouXa1qGmq+-hiDwYQYOghBEAKqwKYxOKERIpIhAgCyYCyAj2uUOiF0mieTik3UqMhqSleN0QhgUOhUbzUKhk9jKch+-T+QWGQJBUHBkKmMzmixW60BYHQSJROQO+SOUmxVKUniUcjkUgVLjlpOOCqecj0LyKmmVeiUTIGrPhwo5SKwfAgsChlBh5CSqSFIuFmGt8B2qP2eVARxUeNKCo02k0cllTRc6qUalsCtU731DikvV+gSGZuBY0t7pttB5s3mS3Qq0mG0Rbo9YrREr9iHUMkIUnKHSklQVKnqtz0BkImjUbmeShcVmejL6Jozm0oECi8xmyxIC3iEGXtFBAAUACIAQQAKgBRNgbgBKVAAYgwADIH6s+jEIOV6Qgj1oxnTBkfq7qNlxyT4aC4saaPcVLGiyU6hDOc48AuS5EKgPAQPgYwbnBa6xPasLOpOAJQZAMHoQhSEoREaF8Iuy4ILCADGKEiAA2jIAC6d7opKiBPi+rRtB+-5fg0CDqM+YafG8+jWLI2jgemeHpAR5DzhR8GEIhyEcuRlFgPm0yFvyJaCrhwz4bOimwcpy6qSRGlEdRjp8HRPpMaxXrir6BSPvo3Fvu0zT8Zo6oDmoTb-p8cjaFcsjqDJ-zGfJpn0Mw7BUKCe5sbWHm6CU2jWKGnhaFSeLqv2jZKMOehOE49i0v+MWmtOYw0OgdrxPZzpQU16XuUcAC0-aPGGSgVQOTS4ko2jfjGhC0gB1WeDIBguHVkGjBETU6byRYCmW06da5NbdZiKalLl+p-k4XgTYJNhxuVNKGCB77fEy5DWnAYhGWkFDUBgXUPoqLiKOoxIqGVejNKoxXPCUMgjZ8zj6sORoThBclhBE2y8N67F1o+xIvhoejavqEX-gFgl6IGFWOC2bzWNqy0AuyYxcpMf0cQghjqn+JT6GJeJynIqrI2msWZhalY2uzuN9dojwpn+epDvqHjRkUL7KBDEljiLzKyXF32mUpWkwquRCvQeuls-t94c606s8c0A5C00UjqqDja5cUXQRXiDiMwb0FmURpuWQW1tY25D7242jsxn+bi6IFhh2K4qjKP+fsqAHX1B8bKkkGb0seR0gNx87idu4JtIwzoxTdELzbheOov1SZhEWcR6moURxdHCOgPhsBoNDRDKju84hCfHS4ZAXPqg59OCn58ufeFODQPD2DY-FUqGsASmdShgvKP67nClrwgQu2EPIPb2V4+CVNf7w5TOphs22en2LDVrb9Ns4w8j1coU8iafDKJVWQagDDqmOsOKBVhXDgweNJb+ppL59TcH2ZQw1E5jSurcYK8CHCODfE0B4vRfBAA */
@@ -70,6 +147,12 @@ export const authMachine =
checkPermissions: {
data: TypesGen.UserAuthorizationResponse
}
getSSHKey: {
data: TypesGen.GitSSHKey
}
regenerateSSHKey: {
data: TypesGen.GitSSHKey
}
},
},
id: "authState",
@@ -197,6 +280,7 @@ export const authMachine =
},
},
},
ssh: sshState,
},
on: {
SIGN_OUT: {
@@ -249,6 +333,9 @@ export const authMachine =
checks: permissionsToCheck,
})
},
// SSH
getSSHKey: () => API.getUserSSHKey(),
regenerateSSHKey: () => API.regenerateUserSSHKey(),
},
actions: {
assignMe: assign({
@@ -302,6 +389,28 @@ export const authMachine =
clearGetPermissionsError: assign({
checkPermissionsError: (_) => undefined,
}),
// SSH
assignSSHKey: assign({
sshKey: (_, event) => event.data,
}),
assignGetSSHKeyError: assign({
getSSHKeyError: (_, event) => event.data,
}),
clearGetSSHKeyError: assign({
getSSHKeyError: (_) => undefined,
}),
assignRegenerateSSHKeyError: assign({
regenerateSSHKeyError: (_, event) => event.data,
}),
clearRegenerateSSHKeyError: assign({
regenerateSSHKeyError: (_) => undefined,
}),
notifySuccessSSHKeyRegenerated: () => {
displaySuccess(Language.successRegenerateSSHKey)
},
notifySSHKeyRegenerationError: () => {
displayError(Language.errorRegenerateSSHKey)
},
},
},
)