Skip to content

Commit 7d2450b

Browse files
mustard-mhiQQBot
authored andcommitted
[dashboard] ssh keys setting support
1 parent 785c041 commit 7d2450b

File tree

9 files changed

+339
-64
lines changed

9 files changed

+339
-64
lines changed

components/dashboard/src/App.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
settingsPathTeamsJoin,
3535
settingsPathTeamsNew,
3636
settingsPathVariables,
37+
settingsPathSSHKeys,
3738
} from "./settings/settings.routes";
3839
import {
3940
projectsPathInstallGitHubApp,
@@ -56,6 +57,7 @@ const Billing = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/
5657
const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Plans"));
5758
const Teams = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Teams"));
5859
const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/EnvironmentVariables"));
60+
const SSHKeys = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/SSHKeys"));
5961
const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Integrations"));
6062
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Preferences"));
6163
const Open = React.lazy(() => import(/* webpackPrefetch: true */ "./start/Open"));
@@ -352,6 +354,7 @@ function App() {
352354
<Route path={settingsPathBilling} exact component={Billing} />
353355
<Route path={settingsPathPlans} exact component={Plans} />
354356
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
357+
<Route path={settingsPathSSHKeys} exact component={SSHKeys} />
355358
<Route path={settingsPathPreferences} exact component={Preferences} />
356359
<Route path={projectsPathInstallGitHubApp} exact component={InstallGitHubApp} />
357360
<Route path="/from-referrer" exact component={FromReferrer} />

components/dashboard/src/components/ItemsList.tsx

+11-4
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ export function ItemsList(props: { children?: React.ReactNode; className?: strin
1010
return <div className={`flex flex-col space-y-2 ${props.className || ""}`}>{props.children}</div>;
1111
}
1212

13-
export function Item(props: { children?: React.ReactNode; className?: string; header?: boolean }) {
13+
export function Item(props: { children?: React.ReactNode; className?: string; header?: boolean; solid?: boolean }) {
14+
// cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700
15+
const solidClassName = props.solid ? "bg-gray-50 dark:bg-gray-800" : "hover:bg-gray-100 dark:hover:bg-gray-800";
1416
const headerClassName = "text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800";
15-
const notHeaderClassName = "rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light";
17+
const notHeaderClassName = "rounded-xl focus:bg-gitpod-kumquat-light " + solidClassName;
1618
return (
1719
<div
1820
className={`flex flex-grow flex-row w-full p-3 justify-between transition ease-in-out ${
@@ -32,10 +34,15 @@ export function ItemFieldIcon(props: { children?: React.ReactNode; className?: s
3234
return <div className={`flex self-center w-8 ${props.className || ""}`}>{props.children}</div>;
3335
}
3436

35-
export function ItemFieldContextMenu(props: { menuEntries: ContextMenuEntry[]; className?: string }) {
37+
export function ItemFieldContextMenu(props: {
38+
menuEntries: ContextMenuEntry[];
39+
className?: string;
40+
position?: "start" | "center" | "end";
41+
}) {
42+
const cls = "self-" + (props.position ?? "center");
3643
return (
3744
<div
38-
className={`flex self-center hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer w-8 ${
45+
className={`flex hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer w-8 ${cls} ${
3946
props.className || ""
4047
}`}
4148
>

components/dashboard/src/components/Modal.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default function Modal(props: {
1919
closeable?: boolean;
2020
className?: string;
2121
onClose: () => void;
22-
onEnter?: () => boolean;
22+
onEnter?: () => boolean | Promise<boolean>;
2323
}) {
2424
const closeModal = (manner: CloseModalManner) => {
2525
props.onClose();
@@ -36,7 +36,7 @@ export default function Modal(props: {
3636
.then()
3737
.catch(console.error);
3838
};
39-
const handler = (evt: KeyboardEvent) => {
39+
const handler = async (evt: KeyboardEvent) => {
4040
if (!props.visible) {
4141
return;
4242
}
@@ -48,7 +48,7 @@ export default function Modal(props: {
4848
}
4949
if (evt.key === "Enter") {
5050
if (props.onEnter) {
51-
if (props.onEnter()) {
51+
if (await props.onEnter()) {
5252
closeModal("enter");
5353
}
5454
} else {
+3
Loading

components/dashboard/src/index.css

+3
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,14 @@
7777
@apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500;
7878
}
7979

80+
textarea,
8081
input[type="text"],
8182
input[type="search"],
8283
input[type="password"],
8384
select {
8485
@apply block w-56 text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-md border border-gray-300 dark:border-gray-500 focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0;
8586
}
87+
textarea::placeholder,
8688
input[type="text"]::placeholder,
8789
input[type="search"]::placeholder,
8890
input[type="password"]::placeholder {
@@ -93,6 +95,7 @@
9395
select.error {
9496
@apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red;
9597
}
98+
textarea[disabled],
9699
input[disabled] {
97100
@apply bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500;
98101
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useContext, useEffect, useState } from "react";
8+
import { PageWithSubMenu } from "../components/PageWithSubMenu";
9+
import getSettingsMenu from "./settings-menu";
10+
import { PaymentContext } from "../payment-context";
11+
import Modal from "../components/Modal";
12+
import Alert from "../components/Alert";
13+
import { Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
14+
import ConfirmationModal from "../components/ConfirmationModal";
15+
import { SSHPublicKeyValue, UserSSHPublicKeyValue } from "@gitpod/gitpod-protocol";
16+
import { getGitpodService } from "../service/service";
17+
import moment from "moment";
18+
19+
interface AddModalProps {
20+
value: SSHPublicKeyValue;
21+
onClose: () => void;
22+
onSave: () => void;
23+
}
24+
25+
interface DeleteModalProps {
26+
value: UserSSHPublicKeyValue;
27+
onConfirm: () => void;
28+
onClose: () => void;
29+
}
30+
31+
export function AddSSHKeyModal(props: AddModalProps) {
32+
const [errorMsg, setErrorMsg] = useState("");
33+
34+
const [value, setValue] = useState({ ...props.value });
35+
const update = (pev: Partial<SSHPublicKeyValue>) => {
36+
setValue({ ...value, ...pev });
37+
setErrorMsg("");
38+
};
39+
40+
useEffect(() => {
41+
setValue({ ...props.value });
42+
setErrorMsg("");
43+
}, [props.value]);
44+
45+
const save = async () => {
46+
const tmp = SSHPublicKeyValue.validate(value);
47+
if (tmp) {
48+
setErrorMsg(tmp);
49+
return false;
50+
}
51+
try {
52+
await getGitpodService().server.addSSHPublicKey(value);
53+
} catch (e) {
54+
setErrorMsg(e.message.replace("Request addSSHPublicKey failed with message: ", ""));
55+
return false;
56+
}
57+
props.onClose();
58+
props.onSave();
59+
return true;
60+
};
61+
62+
return (
63+
<Modal
64+
title="New SSH Key"
65+
buttons={
66+
<button className="ml-2" onClick={save}>
67+
Add SSH Key
68+
</button>
69+
}
70+
visible={true}
71+
onClose={props.onClose}
72+
onEnter={save}
73+
>
74+
<>
75+
{errorMsg.length > 0 && (
76+
<Alert type="error" className="mb-2">
77+
{errorMsg}
78+
</Alert>
79+
)}
80+
</>
81+
<div className="text-gray-500 dark:text-gray-400 text-md">
82+
Add an SSH key for secure access workspaces via SSH.{" "}
83+
<a href="/docs/configure/ssh" target="gitpod-ssh-doc" className="gp-link">
84+
Learn more
85+
</a>
86+
</div>
87+
<Alert type="info" className="mt-2">
88+
SSH key are used to connect securely to workspaces.{" "}
89+
<a
90+
href="https://www.gitpod.io/docs/configure/ssh#create-an-ssh-key"
91+
target="gitpod-create-ssh-key-doc"
92+
className="gp-link"
93+
>
94+
Learn how to create an SSH Key
95+
</a>
96+
</Alert>
97+
<div className="mt-2">
98+
<h4>Key</h4>
99+
<textarea
100+
autoFocus
101+
style={{ height: "160px" }}
102+
className="w-full resize-none"
103+
value={value.key}
104+
placeholder="Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256',
105+
'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
106+
'ssh-ed25519',
107+
108+
109+
onChange={(v) => update({ key: v.target.value })}
110+
/>
111+
</div>
112+
<div className="mt-4">
113+
<h4>Title</h4>
114+
<input
115+
className="w-full"
116+
type="text"
117+
placeholder="e.g. laptop"
118+
value={value.name}
119+
onChange={(v) => {
120+
update({ name: v.target.value });
121+
}}
122+
/>
123+
</div>
124+
</Modal>
125+
);
126+
}
127+
128+
export function DeleteSSHKeyModal(props: DeleteModalProps) {
129+
const confirmDelete = async () => {
130+
await getGitpodService().server.deleteSSHPublicKey(props.value.id!);
131+
props.onConfirm();
132+
props.onClose();
133+
};
134+
return (
135+
<ConfirmationModal
136+
title="Delete SSH Key"
137+
areYouSureText="Are you sure you want to delete this SSH Key?"
138+
buttonText="Delete SSH Key"
139+
onClose={props.onClose}
140+
onConfirm={confirmDelete}
141+
>
142+
<Item solid>
143+
<KeyItem sshKey={props.value}></KeyItem>
144+
</Item>
145+
</ConfirmationModal>
146+
);
147+
}
148+
149+
export default function SSHKeys() {
150+
const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext);
151+
152+
const [dataList, setDataList] = useState<UserSSHPublicKeyValue[]>([]);
153+
const [currentData, setCurrentData] = useState<SSHPublicKeyValue>({ name: "", key: "" });
154+
const [currentDelData, setCurrentDelData] = useState<UserSSHPublicKeyValue>();
155+
const [showAddModal, setShowAddModal] = useState(false);
156+
const [showDelModal, setShowDelModal] = useState(false);
157+
158+
const loadData = () => {
159+
getGitpodService()
160+
.server.getSSHPublicKeys()
161+
.then((r) => setDataList(r));
162+
};
163+
164+
useEffect(() => {
165+
loadData();
166+
}, []);
167+
168+
const addOne = () => {
169+
setCurrentData({ name: "", key: "" });
170+
setShowAddModal(true);
171+
setShowDelModal(false);
172+
};
173+
174+
const deleteOne = (value: UserSSHPublicKeyValue) => {
175+
setCurrentDelData(value);
176+
setShowAddModal(false);
177+
setShowDelModal(true);
178+
};
179+
180+
return (
181+
<PageWithSubMenu
182+
subMenu={getSettingsMenu({ showPaymentUI, showUsageBasedUI })}
183+
title="SSH Keys"
184+
subtitle="Connect securely to workspaces."
185+
>
186+
{showAddModal && (
187+
<AddSSHKeyModal value={currentData} onSave={loadData} onClose={() => setShowAddModal(false)} />
188+
)}
189+
{showDelModal && (
190+
<DeleteSSHKeyModal
191+
value={currentDelData!}
192+
onConfirm={loadData}
193+
onClose={() => setShowDelModal(false)}
194+
/>
195+
)}
196+
<div className="flex items-start sm:justify-between mb-2">
197+
<div>
198+
<h3>SSH Keys</h3>
199+
<h2 className="text-gray-500">Create and manage SSH keys.</h2>
200+
</div>
201+
{dataList.length !== 0 ? (
202+
<div className="mt-3 flex">
203+
<button onClick={addOne} className="ml-2">
204+
New SSH Key
205+
</button>
206+
</div>
207+
) : null}
208+
</div>
209+
{dataList.length === 0 ? (
210+
<div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full h-96">
211+
<div className="pt-28 flex flex-col items-center w-112 m-auto">
212+
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No SSH Keys</h3>
213+
<div className="text-center pb-6 text-gray-500">
214+
SSH keys allow you to establish a <b>secure connection</b> between your <b>computer</b> and{" "}
215+
<b>workspaces</b>.
216+
</div>
217+
<button onClick={addOne}>New SSH Key</button>
218+
</div>
219+
</div>
220+
) : (
221+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
222+
{dataList.map((key) => {
223+
return (
224+
<Item solid className="items-start">
225+
<KeyItem sshKey={key}></KeyItem>
226+
<ItemFieldContextMenu
227+
position="start"
228+
menuEntries={[
229+
{
230+
title: "Delete",
231+
customFontStyle:
232+
"text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",
233+
onClick: () => deleteOne(key),
234+
},
235+
]}
236+
/>
237+
</Item>
238+
);
239+
})}
240+
</div>
241+
)}
242+
</PageWithSubMenu>
243+
);
244+
}
245+
246+
function KeyItem(props: { sshKey: UserSSHPublicKeyValue }) {
247+
const key = props.sshKey;
248+
return (
249+
<ItemField className="flex flex-col gap-y box-border overflow-hidden">
250+
<p className="truncate text-gray-400 dark:text-gray-600">SHA256:{key.fingerprint}</p>
251+
<div className="truncate my-1 text-xl text-gray-800 dark:text-gray-100 font-semibold">{key.name}</div>
252+
<p className="truncate mt-4">Added on {moment(key.creationTime).format("MMM D, YYYY, hh:mm A")}</p>
253+
{!!key.lastUsedTime && (
254+
<p className="truncate">Last used on {moment(key.lastUsedTime).format("MMM D, YYYY, hh:mm A")}</p>
255+
)}
256+
</ItemField>
257+
);
258+
}

components/dashboard/src/settings/settings-menu.ts

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
settingsPathPreferences,
1515
settingsPathTeams,
1616
settingsPathVariables,
17+
settingsPathSSHKeys,
1718
} from "./settings.routes";
1819

1920
export default function getSettingsMenu(params: { showPaymentUI?: boolean; showUsageBasedUI?: boolean }) {
@@ -50,6 +51,10 @@ export default function getSettingsMenu(params: { showPaymentUI?: boolean; showU
5051
title: "Variables",
5152
link: [settingsPathVariables],
5253
},
54+
{
55+
title: "SSH Keys",
56+
link: [settingsPathSSHKeys],
57+
},
5358
{
5459
title: "Integrations",
5560
link: [settingsPathIntegrations, "/access-control"],

components/dashboard/src/settings/settings.routes.ts

+2
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export const settingsPathTeamsJoin = [settingsPathTeams, "join"].join("/");
1818
export const settingsPathTeamsNew = [settingsPathTeams, "new"].join("/");
1919

2020
export const settingsPathVariables = "/variables";
21+
22+
export const settingsPathSSHKeys = "/keys";

0 commit comments

Comments
 (0)