Skip to content

Commit f5eed0a

Browse files
committed
[dashboard] ssh keys setting support
1 parent 483a213 commit f5eed0a

File tree

9 files changed

+338
-64
lines changed

9 files changed

+338
-64
lines changed

components/dashboard/src/App.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
settingsPathTeamsJoin,
3737
settingsPathTeamsNew,
3838
settingsPathVariables,
39+
settingsPathSSHKeys,
3940
} from "./settings/settings.routes";
4041
import {
4142
projectsPathInstallGitHubApp,
@@ -58,6 +59,7 @@ const Billing = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/
5859
const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Plans"));
5960
const Teams = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Teams"));
6061
const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/EnvironmentVariables"));
62+
const SSHKeys = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/SSHKeys"));
6163
const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Integrations"));
6264
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Preferences"));
6365
const Open = React.lazy(() => import(/* webpackPrefetch: true */ "./start/Open"));
@@ -364,6 +366,7 @@ function App() {
364366
<Route path={settingsPathBilling} exact component={Billing} />
365367
<Route path={settingsPathPlans} exact component={Plans} />
366368
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
369+
<Route path={settingsPathSSHKeys} exact component={SSHKeys} />
367370
<Route path={settingsPathPreferences} exact component={Preferences} />
368371
<Route path={projectsPathInstallGitHubApp} exact component={InstallGitHubApp} />
369372
<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 (evt.defaultPrevented) {
4141
return;
4242
}
@@ -45,7 +45,7 @@ export default function Modal(props: {
4545
}
4646
if (evt.key === "Enter") {
4747
if (props.onEnter) {
48-
if (props.onEnter()) {
48+
if (await props.onEnter()) {
4949
closeModal("enter");
5050
}
5151
} 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,257 @@
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: KeyData;
27+
onConfirm: () => void;
28+
onClose: () => void;
29+
}
30+
31+
type KeyData = SSHPublicKeyValue & { id?: string };
32+
33+
export function AddSSHKeyModal(props: AddModalProps) {
34+
const [errorMsg, setErrorMsg] = useState("");
35+
36+
const [value, setValue] = useState({ ...props.value });
37+
const update = (pev: Partial<SSHPublicKeyValue>) => {
38+
setValue({ ...value, ...pev });
39+
setErrorMsg("");
40+
};
41+
42+
useEffect(() => {
43+
setValue({ ...props.value });
44+
setErrorMsg("");
45+
}, [props.value]);
46+
47+
const save = async () => {
48+
const tmp = SSHPublicKeyValue.validate(value);
49+
if (tmp) {
50+
setErrorMsg(tmp);
51+
return false;
52+
}
53+
try {
54+
await getGitpodService().server.addSSHPublicKey(value);
55+
} catch (e) {
56+
setErrorMsg(e.message);
57+
return false;
58+
}
59+
props.onClose();
60+
props.onSave();
61+
return true;
62+
};
63+
64+
return (
65+
<Modal
66+
title="New SSH Key"
67+
buttons={
68+
<button className="ml-2" onClick={save}>
69+
Add Key
70+
</button>
71+
}
72+
visible={true}
73+
onClose={props.onClose}
74+
onEnter={save}
75+
>
76+
<>
77+
{errorMsg.length > 0 && (
78+
<Alert type="error" className="mb-2">
79+
{errorMsg}
80+
</Alert>
81+
)}
82+
</>
83+
<div className="text-gray-500 dark:text-gray-400 text-md">
84+
Add an SSH key for secure access workspaces via SSH. Learn more
85+
</div>
86+
<Alert type="info" className="mt-2">
87+
SSH key are used to connect securely to workspaces.{" "}
88+
<a
89+
href="https://www.gitpod.io/docs/configure/ssh#create-an-ssh-key"
90+
target="gitpod-create-ssh-key-doc"
91+
className="gp-link"
92+
>
93+
Learn how to create an SSH Key
94+
</a>
95+
</Alert>
96+
<div className="mt-2">
97+
<h4>Key</h4>
98+
<textarea
99+
autoFocus
100+
style={{ height: "160px" }}
101+
className="w-full resize-none"
102+
value={value.key}
103+
placeholder="Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256',
104+
'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
105+
'ssh-ed25519',
106+
107+
108+
onChange={(v) => update({ key: v.target.value })}
109+
/>
110+
</div>
111+
<div className="mt-2">
112+
<h4>Title</h4>
113+
<input
114+
className="w-full"
115+
type="text"
116+
placeholder="e.g. laptop"
117+
value={value.name}
118+
onChange={(v) => {
119+
update({ name: v.target.value });
120+
}}
121+
/>
122+
</div>
123+
</Modal>
124+
);
125+
}
126+
127+
export function DeleteSSHKeyModal(props: DeleteModalProps) {
128+
const confirmDelete = async () => {
129+
await getGitpodService().server.deleteSSHPublicKey(props.value.id!);
130+
props.onConfirm();
131+
props.onClose();
132+
};
133+
return (
134+
<ConfirmationModal
135+
title="Delete SSH Key"
136+
areYouSureText="This action CANNOT be undone. This will permanently delete the SSH key and if you'd like to use it in the future, you will need to upload it again."
137+
buttonText="Delete SSH Key"
138+
onClose={props.onClose}
139+
onConfirm={confirmDelete}
140+
/>
141+
);
142+
}
143+
144+
export default function SSHKeys() {
145+
const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext);
146+
147+
const [dataList, setDataList] = useState<UserSSHPublicKeyValue[]>([]);
148+
const [currentData, setCurrentData] = useState<KeyData>({ name: "", key: "" });
149+
const [showAddModal, setShowAddModal] = useState(false);
150+
const [showDelModal, setShowDelModal] = useState(false);
151+
152+
const loadData = () => {
153+
getGitpodService()
154+
.server.getSSHPublicKeys()
155+
.then((r) => setDataList(r));
156+
};
157+
158+
useEffect(() => {
159+
loadData();
160+
}, []);
161+
162+
const addOne = () => {
163+
setCurrentData({ name: "", key: "" });
164+
setShowAddModal(true);
165+
setShowDelModal(false);
166+
};
167+
168+
const deleteOne = (value: UserSSHPublicKeyValue) => {
169+
setCurrentData({ id: value.id, name: value.name, key: "" });
170+
setShowAddModal(false);
171+
setShowDelModal(true);
172+
};
173+
174+
return (
175+
<PageWithSubMenu
176+
subMenu={getSettingsMenu({ showPaymentUI, showUsageBasedUI })}
177+
title="SSH Keys"
178+
subtitle="Connect securely to workspaces."
179+
>
180+
{showAddModal && (
181+
<AddSSHKeyModal value={currentData} onSave={loadData} onClose={() => setShowAddModal(false)} />
182+
)}
183+
{showDelModal && (
184+
<DeleteSSHKeyModal value={currentData} onConfirm={loadData} onClose={() => setShowDelModal(false)} />
185+
)}
186+
<div className="flex items-start sm:justify-between mb-2">
187+
<div>
188+
<h3>SSH Keys</h3>
189+
<h2 className="text-gray-500">
190+
Create and manage SSH keys.{" "}
191+
<a href="https://www.gitpod.io/docs/configure/ssh" target="gitpod-ssh-doc" className="gp-link">
192+
More details
193+
</a>
194+
</h2>
195+
</div>
196+
{dataList.length !== 0 ? (
197+
<div className="mt-3 flex">
198+
<button onClick={addOne} className="ml-2">
199+
New SSH Key
200+
</button>
201+
</div>
202+
) : null}
203+
</div>
204+
{dataList.length === 0 ? (
205+
<div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full h-96">
206+
<div className="pt-28 flex flex-col items-center w-120 m-auto">
207+
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No SSH Keys</h3>
208+
<div className="text-center pb-6 text-gray-500">
209+
SSH keys allow you to establish a <b>secure connection</b> between your <b>computer</b> and{" "}
210+
<b>workspaces</b>.
211+
</div>
212+
<button onClick={addOne}>New SSH Key</button>
213+
</div>
214+
</div>
215+
) : (
216+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
217+
{dataList.map((key) => {
218+
return (
219+
<Item solid className="items-start">
220+
<ItemField className="flex flex-col gap-y box-border overflow-hidden">
221+
<p className="truncate text-gray-400 dark:text-gray-600">
222+
{key.fingerprint
223+
.toUpperCase()
224+
.match(/.{1,2}/g)
225+
?.join(":") ?? key.fingerprint}
226+
</p>
227+
<div className="truncate my-1 text-xl text-gray-800 dark:text-gray-100 font-semibold">
228+
{key.name}
229+
</div>
230+
<p className="truncate mt-4">
231+
Added on {moment(key.creationTime).format("MMM D, YYYY, hh:mm A")}
232+
</p>
233+
{!!key.lastUsedTime && (
234+
<p className="truncate">
235+
Last used on {moment(key.lastUsedTime).format("MMM D, YYYY, hh:mm A")}
236+
</p>
237+
)}
238+
</ItemField>
239+
<ItemFieldContextMenu
240+
position="start"
241+
menuEntries={[
242+
{
243+
title: "Delete",
244+
customFontStyle:
245+
"text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",
246+
onClick: () => deleteOne(key),
247+
},
248+
]}
249+
/>
250+
</Item>
251+
);
252+
})}
253+
</div>
254+
)}
255+
</PageWithSubMenu>
256+
);
257+
}

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)