mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add SSH Keys page on /preferences/ssh-keys (#1478)
This commit is contained in:
@@ -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}</>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user