Skip to content

Commit abbe548

Browse files
feat: Add SSH Keys page on /preferences/ssh-keys (#1478)
1 parent 5447c4a commit abbe548

File tree

7 files changed

+284
-16
lines changed

7 files changed

+284
-16
lines changed

site/src/api/api.ts

+10
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,13 @@ export const updateUserRoles = async (
206206
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles })
207207
return response.data
208208
}
209+
210+
export const getUserSSHKey = async (userId = "me"): Promise<TypesGen.GitSSHKey> => {
211+
const response = await axios.get<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`)
212+
return response.data
213+
}
214+
215+
export const regenerateUserSSHKey = async (userId = "me"): Promise<TypesGen.GitSSHKey> => {
216+
const response = await axios.put<TypesGen.GitSSHKey>(`/api/v2/users/${userId}/gitsshkey`)
217+
return response.data
218+
}

site/src/pages/PreferencesPages/AccountPage/SSHKeysPage.tsx

-12
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { fireEvent, screen, within } from "@testing-library/react"
2+
import React from "react"
3+
import * as API from "../../../api/api"
4+
import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar"
5+
import { MockGitSSHKey, renderWithAuth } from "../../../testHelpers/renderHelpers"
6+
import { Language as authXServiceLanguage } from "../../../xServices/auth/authXService"
7+
import { Language as SSHKeysPageLanguage, SSHKeysPage } from "./SSHKeysPage"
8+
9+
describe("SSH Keys Page", () => {
10+
it("shows the SSH key", async () => {
11+
renderWithAuth(<SSHKeysPage />)
12+
await screen.findByText(MockGitSSHKey.public_key)
13+
})
14+
15+
describe("regenerate SSH key", () => {
16+
describe("when it is success", () => {
17+
it("shows a success message and updates the ssh key on the page", async () => {
18+
renderWithAuth(
19+
<>
20+
<SSHKeysPage />
21+
<GlobalSnackbar />
22+
</>,
23+
)
24+
25+
// Wait to the ssh be rendered on the screen
26+
await screen.findByText(MockGitSSHKey.public_key)
27+
28+
// Click on the "Regenerate" button to display the confirm dialog
29+
const regenerateButton = screen.getByRole("button", { name: SSHKeysPageLanguage.regenerateLabel })
30+
fireEvent.click(regenerateButton)
31+
const confirmDialog = screen.getByRole("dialog")
32+
expect(confirmDialog).toHaveTextContent(SSHKeysPageLanguage.regenerateDialogMessage)
33+
34+
const newUserSSHKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDSC/ouD/LqiT1Rd99vDv/MwUmqzJuinLTMTpk5kVy66"
35+
jest.spyOn(API, "regenerateUserSSHKey").mockResolvedValueOnce({
36+
...MockGitSSHKey,
37+
public_key: newUserSSHKey,
38+
})
39+
40+
// Click on the "Confirm" button
41+
const confirmButton = within(confirmDialog).getByRole("button", { name: SSHKeysPageLanguage.confirmLabel })
42+
fireEvent.click(confirmButton)
43+
44+
// Check if the success message is displayed
45+
await screen.findByText(authXServiceLanguage.successRegenerateSSHKey)
46+
47+
// Check if the API was called correctly
48+
expect(API.regenerateUserSSHKey).toBeCalledTimes(1)
49+
50+
// Check if the SSH key is updated
51+
await screen.findByText(newUserSSHKey)
52+
})
53+
})
54+
55+
describe("when it fails", () => {
56+
it("shows an error message", async () => {
57+
renderWithAuth(
58+
<>
59+
<SSHKeysPage />
60+
<GlobalSnackbar />
61+
</>,
62+
)
63+
64+
// Wait to the ssh be rendered on the screen
65+
await screen.findByText(MockGitSSHKey.public_key)
66+
67+
jest.spyOn(API, "regenerateUserSSHKey").mockRejectedValueOnce({})
68+
69+
// Click on the "Regenerate" button to display the confirm dialog
70+
const regenerateButton = screen.getByRole("button", { name: SSHKeysPageLanguage.regenerateLabel })
71+
fireEvent.click(regenerateButton)
72+
const confirmDialog = screen.getByRole("dialog")
73+
expect(confirmDialog).toHaveTextContent(SSHKeysPageLanguage.regenerateDialogMessage)
74+
75+
// Click on the "Confirm" button
76+
const confirmButton = within(confirmDialog).getByRole("button", { name: SSHKeysPageLanguage.confirmLabel })
77+
fireEvent.click(confirmButton)
78+
79+
// Check if the error message is displayed
80+
await screen.findByText(authXServiceLanguage.errorRegenerateSSHKey)
81+
82+
// Check if the API was called correctly
83+
expect(API.regenerateUserSSHKey).toBeCalledTimes(1)
84+
})
85+
})
86+
})
87+
})
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,76 @@
1-
import React from "react"
1+
import Box from "@material-ui/core/Box"
2+
import Button from "@material-ui/core/Button"
3+
import CircularProgress from "@material-ui/core/CircularProgress"
4+
import { useActor } from "@xstate/react"
5+
import React, { useContext, useEffect } from "react"
6+
import { CodeBlock } from "../../../components/CodeBlock/CodeBlock"
7+
import { ConfirmDialog } from "../../../components/ConfirmDialog/ConfirmDialog"
28
import { Section } from "../../../components/Section/Section"
9+
import { Stack } from "../../../components/Stack/Stack"
10+
import { XServiceContext } from "../../../xServices/StateContext"
311

4-
const Language = {
12+
export const Language = {
513
title: "SSH Keys",
614
description:
715
"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.",
16+
regenerateLabel: "Regenerate",
17+
regenerateDialogTitle: "Regenerate SSH Key?",
18+
regenerateDialogMessage:
19+
"You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
20+
confirmLabel: "Confirm",
21+
cancelLabel: "Cancel",
822
}
923

1024
export const SSHKeysPage: React.FC = () => {
11-
return <Section title={Language.title} description={Language.description} />
25+
const xServices = useContext(XServiceContext)
26+
const [authState, authSend] = useActor(xServices.authXService)
27+
const { sshKey } = authState.context
28+
29+
useEffect(() => {
30+
authSend({ type: "GET_SSH_KEY" })
31+
}, [authSend])
32+
33+
return (
34+
<>
35+
<Section title={Language.title} description={Language.description}>
36+
{!sshKey && (
37+
<Box p={4}>
38+
<CircularProgress size={26} />
39+
</Box>
40+
)}
41+
42+
{sshKey && (
43+
<Stack>
44+
<CodeBlock lines={[sshKey.public_key.trim()]} />
45+
<div>
46+
<Button
47+
color="primary"
48+
onClick={() => {
49+
authSend({ type: "REGENERATE_SSH_KEY" })
50+
}}
51+
>
52+
{Language.regenerateLabel}
53+
</Button>
54+
</div>
55+
</Stack>
56+
)}
57+
</Section>
58+
59+
<ConfirmDialog
60+
type="delete"
61+
hideCancel={false}
62+
open={authState.matches("signedIn.ssh.loaded.confirmSSHKeyRegenerate")}
63+
confirmLoading={authState.matches("signedIn.ssh.loaded.regeneratingSSHKey")}
64+
title={Language.regenerateDialogTitle}
65+
confirmText={Language.confirmLabel}
66+
onConfirm={() => {
67+
authSend({ type: "CONFIRM_REGENERATE_SSH_KEY" })
68+
}}
69+
onClose={() => {
70+
authSend({ type: "CANCEL_REGENERATE_SSH_KEY" })
71+
}}
72+
description={<>{Language.regenerateDialogMessage}</>}
73+
/>
74+
</>
75+
)
1276
}

site/src/testHelpers/entities.ts

+7
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,10 @@ export const MockAuthMethods: TypesGen.AuthMethods = {
205205
password: true,
206206
github: false,
207207
}
208+
209+
export const MockGitSSHKey: TypesGen.GitSSHKey = {
210+
user_id: "1fa0200f-7331-4524-a364-35770666caa7",
211+
created_at: "2022-05-16T14:30:34.148205897Z",
212+
updated_at: "2022-05-16T15:29:10.302441433Z",
213+
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJOQRIM7kE30rOzrfy+/+R+nQGCk7S9pioihy+2ARbq",
214+
}

site/src/testHelpers/handlers.ts

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export const handlers = [
6868

6969
return res(ctx.status(200), ctx.json(response))
7070
}),
71+
rest.get("/api/v2/users/:userId/gitsshkey", async (req, res, ctx) => {
72+
return res(ctx.status(200), ctx.json(M.MockGitSSHKey))
73+
}),
7174

7275
// workspaces
7376
rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => {

site/src/xServices/auth/authXService.ts

+110-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { assign, createMachine } from "xstate"
22
import * as API from "../../api/api"
33
import * as TypesGen from "../../api/typesGenerated"
4-
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
4+
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
55

66
export const Language = {
77
successProfileUpdate: "Updated preferences.",
8+
successRegenerateSSHKey: "SSH Key regenerated successfully",
9+
errorRegenerateSSHKey: "Error on regenerate the SSH Key",
810
}
911

1012
export const checks = {
@@ -31,12 +33,87 @@ export interface AuthContext {
3133
methods?: TypesGen.AuthMethods
3234
permissions?: Permissions
3335
checkPermissionsError?: Error | unknown
36+
// SSH
37+
sshKey?: TypesGen.GitSSHKey
38+
getSSHKeyError?: Error | unknown
39+
regenerateSSHKeyError?: Error | unknown
3440
}
3541

3642
export type AuthEvent =
3743
| { type: "SIGN_OUT" }
3844
| { type: "SIGN_IN"; email: string; password: string }
3945
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
46+
| { type: "GET_SSH_KEY" }
47+
| { type: "REGENERATE_SSH_KEY" }
48+
| { type: "CONFIRM_REGENERATE_SSH_KEY" }
49+
| { type: "CANCEL_REGENERATE_SSH_KEY" }
50+
51+
const sshState = {
52+
initial: "idle",
53+
states: {
54+
idle: {
55+
on: {
56+
GET_SSH_KEY: {
57+
target: "gettingSSHKey",
58+
},
59+
},
60+
},
61+
gettingSSHKey: {
62+
entry: "clearGetSSHKeyError",
63+
invoke: {
64+
src: "getSSHKey",
65+
onDone: [
66+
{
67+
actions: ["assignSSHKey"],
68+
target: "#authState.signedIn.ssh.loaded",
69+
},
70+
],
71+
onError: [
72+
{
73+
actions: "assignGetSSHKeyError",
74+
target: "#authState.signedIn.ssh.idle",
75+
},
76+
],
77+
},
78+
},
79+
loaded: {
80+
initial: "idle",
81+
states: {
82+
idle: {
83+
on: {
84+
REGENERATE_SSH_KEY: {
85+
target: "confirmSSHKeyRegenerate",
86+
},
87+
},
88+
},
89+
confirmSSHKeyRegenerate: {
90+
on: {
91+
CANCEL_REGENERATE_SSH_KEY: "idle",
92+
CONFIRM_REGENERATE_SSH_KEY: "regeneratingSSHKey",
93+
},
94+
},
95+
regeneratingSSHKey: {
96+
entry: "clearRegenerateSSHKeyError",
97+
invoke: {
98+
src: "regenerateSSHKey",
99+
onDone: [
100+
{
101+
actions: ["assignSSHKey", "notifySuccessSSHKeyRegenerated"],
102+
target: "#authState.signedIn.ssh.loaded.idle",
103+
},
104+
],
105+
onError: [
106+
{
107+
actions: ["assignRegenerateSSHKeyError", "notifySSHKeyRegenerationError"],
108+
target: "#authState.signedIn.ssh.loaded.idle",
109+
},
110+
],
111+
},
112+
},
113+
},
114+
},
115+
},
116+
}
40117

41118
export const authMachine =
42119
/** @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 =
70147
checkPermissions: {
71148
data: TypesGen.UserAuthorizationResponse
72149
}
150+
getSSHKey: {
151+
data: TypesGen.GitSSHKey
152+
}
153+
regenerateSSHKey: {
154+
data: TypesGen.GitSSHKey
155+
}
73156
},
74157
},
75158
id: "authState",
@@ -197,6 +280,7 @@ export const authMachine =
197280
},
198281
},
199282
},
283+
ssh: sshState,
200284
},
201285
on: {
202286
SIGN_OUT: {
@@ -249,6 +333,9 @@ export const authMachine =
249333
checks: permissionsToCheck,
250334
})
251335
},
336+
// SSH
337+
getSSHKey: () => API.getUserSSHKey(),
338+
regenerateSSHKey: () => API.regenerateUserSSHKey(),
252339
},
253340
actions: {
254341
assignMe: assign({
@@ -302,6 +389,28 @@ export const authMachine =
302389
clearGetPermissionsError: assign({
303390
checkPermissionsError: (_) => undefined,
304391
}),
392+
// SSH
393+
assignSSHKey: assign({
394+
sshKey: (_, event) => event.data,
395+
}),
396+
assignGetSSHKeyError: assign({
397+
getSSHKeyError: (_, event) => event.data,
398+
}),
399+
clearGetSSHKeyError: assign({
400+
getSSHKeyError: (_) => undefined,
401+
}),
402+
assignRegenerateSSHKeyError: assign({
403+
regenerateSSHKeyError: (_, event) => event.data,
404+
}),
405+
clearRegenerateSSHKeyError: assign({
406+
regenerateSSHKeyError: (_) => undefined,
407+
}),
408+
notifySuccessSSHKeyRegenerated: () => {
409+
displaySuccess(Language.successRegenerateSSHKey)
410+
},
411+
notifySSHKeyRegenerationError: () => {
412+
displayError(Language.errorRegenerateSSHKey)
413+
},
305414
},
306415
},
307416
)

0 commit comments

Comments
 (0)