From 4306b3daf8835c8e62950824e1d6a66a20fbf7f4 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 16 May 2022 15:29:38 +0000 Subject: [PATCH 1/2] feat: Add SSH page on user preferences --- site/src/api/api.ts | 10 ++ .../AccountPage/SSHKeysPage.tsx | 12 -- .../SSHKeysPage/SSHKeysPage.tsx | 68 ++++++++++- site/src/xServices/auth/authXService.ts | 111 +++++++++++++++++- 4 files changed, 186 insertions(+), 15 deletions(-) delete mode 100644 site/src/pages/PreferencesPages/AccountPage/SSHKeysPage.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 460f9c447f437..7baa9543175f7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -190,3 +190,13 @@ export const updateUserRoles = async ( const response = await axios.put(`/api/v2/users/${userId}/roles`, { roles }) return response.data } + +export const getUserSSHKey = async (userId = "me"): Promise => { + const response = await axios.get(`/api/v2/users/${userId}/gitsshkey`) + return response.data +} + +export const regenerateUserSSHKey = async (userId = "me"): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/gitsshkey`) + return response.data +} diff --git a/site/src/pages/PreferencesPages/AccountPage/SSHKeysPage.tsx b/site/src/pages/PreferencesPages/AccountPage/SSHKeysPage.tsx deleted file mode 100644 index c190590155563..0000000000000 --- a/site/src/pages/PreferencesPages/AccountPage/SSHKeysPage.tsx +++ /dev/null @@ -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
-} diff --git a/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx b/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx index c190590155563..9037923e87c71 100644 --- a/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx +++ b/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx @@ -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 = { 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
+ const xServices = useContext(XServiceContext) + const [authState, authSend] = useActor(xServices.authXService) + const { sshKey } = authState.context + + useEffect(() => { + authSend({ type: "GET_SSH_KEY" }) + }, [authSend]) + + return ( + <> +
+ {!sshKey && ( + + + + )} + + {sshKey && ( + + +
+ +
+
+ )} +
+ + { + authSend({ type: "CONFIRM_REGENERATE_SSH_KEY" }) + }} + onClose={() => { + authSend({ type: "CANCEL_REGENERATE_SSH_KEY" }) + }} + description={<>{Language.regenerateDialogMessage}} + /> + + ) } diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 86e9dd003d4ac..c5958ca566368 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -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) + }, }, }, ) From 69458968d5495c3997c8020624575f53e104b2a3 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 16 May 2022 16:14:48 +0000 Subject: [PATCH 2/2] Add missing tests --- .../SSHKeysPage/SSHKeysPage.test.tsx | 87 +++++++++++++++++++ .../SSHKeysPage/SSHKeysPage.tsx | 4 +- site/src/testHelpers/entities.ts | 7 ++ site/src/testHelpers/handlers.ts | 3 + 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.test.tsx diff --git a/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.test.tsx b/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.test.tsx new file mode 100644 index 0000000000000..da50ce2e3765b --- /dev/null +++ b/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.test.tsx @@ -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() + 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( + <> + + + , + ) + + // 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( + <> + + + , + ) + + // 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) + }) + }) + }) +}) diff --git a/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx b/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx index 9037923e87c71..af89d724189b3 100644 --- a/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx +++ b/site/src/pages/PreferencesPages/SSHKeysPage/SSHKeysPage.tsx @@ -9,7 +9,7 @@ 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.", @@ -41,7 +41,7 @@ export const SSHKeysPage: React.FC = () => { {sshKey && ( - +