Skip to content

Commit 461e496

Browse files
committed
[dashboard] ssh keys setting support
1 parent 71fb27d commit 461e496

File tree

9 files changed

+331
-64
lines changed

9 files changed

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

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)