Skip to content

Commit

Permalink
[dashboard] ssh keys setting support
Browse files Browse the repository at this point in the history
  • Loading branch information
mustard-mh committed Jun 14, 2022
1 parent 1ebde98 commit 56aa88c
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 64 deletions.
3 changes: 3 additions & 0 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
settingsPathTeamsJoin,
settingsPathTeamsNew,
settingsPathVariables,
settingsPathSSHKeys,
} from "./settings/settings.routes";
import {
projectsPathInstallGitHubApp,
Expand All @@ -58,6 +59,7 @@ const Billing = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/
const Plans = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Plans"));
const Teams = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Teams"));
const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/EnvironmentVariables"));
const SSHKeys = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/SSHKeys"));
const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Integrations"));
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ "./settings/Preferences"));
const Open = React.lazy(() => import(/* webpackPrefetch: true */ "./start/Open"));
Expand Down Expand Up @@ -364,6 +366,7 @@ function App() {
<Route path={settingsPathBilling} exact component={Billing} />
<Route path={settingsPathPlans} exact component={Plans} />
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
<Route path={settingsPathSSHKeys} exact component={SSHKeys} />
<Route path={settingsPathPreferences} exact component={Preferences} />
<Route path={projectsPathInstallGitHubApp} exact component={InstallGitHubApp} />
<Route path="/from-referrer" exact component={FromReferrer} />
Expand Down
15 changes: 11 additions & 4 deletions components/dashboard/src/components/ItemsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ export function ItemsList(props: { children?: React.ReactNode; className?: strin
return <div className={`flex flex-col space-y-2 ${props.className || ""}`}>{props.children}</div>;
}

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

export function ItemFieldContextMenu(props: { menuEntries: ContextMenuEntry[]; className?: string }) {
export function ItemFieldContextMenu(props: {
menuEntries: ContextMenuEntry[];
className?: string;
position?: "start" | "center" | "end";
}) {
const cls = "self-" + (props.position ?? "center");
return (
<div
className={`flex self-center hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer w-8 ${
className={`flex hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer w-8 ${cls} ${
props.className || ""
}`}
>
Expand Down
6 changes: 3 additions & 3 deletions components/dashboard/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function Modal(props: {
closeable?: boolean;
className?: string;
onClose: () => void;
onEnter?: () => boolean;
onEnter?: () => boolean | Promise<boolean>;
}) {
const closeModal = (manner: CloseModalManner) => {
props.onClose();
Expand All @@ -36,7 +36,7 @@ export default function Modal(props: {
.then()
.catch(console.error);
};
const handler = (evt: KeyboardEvent) => {
const handler = async (evt: KeyboardEvent) => {
if (evt.defaultPrevented) {
return;
}
Expand All @@ -45,7 +45,7 @@ export default function Modal(props: {
}
if (evt.key === "Enter") {
if (props.onEnter) {
if (props.onEnter()) {
if (await props.onEnter()) {
closeModal("enter");
}
} else {
Expand Down
3 changes: 3 additions & 0 deletions components/dashboard/src/icons/Key.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions components/dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@
@apply text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500;
}

textarea,
input[type="text"],
input[type="search"],
input[type="password"],
select {
@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;
}
textarea::placeholder,
input[type="text"]::placeholder,
input[type="search"]::placeholder,
input[type="password"]::placeholder {
Expand All @@ -93,6 +95,7 @@
select.error {
@apply border-gitpod-red dark:border-gitpod-red focus:border-gitpod-red dark:focus:border-gitpod-red;
}
textarea[disabled],
input[disabled] {
@apply bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500;
}
Expand Down
250 changes: 250 additions & 0 deletions components/dashboard/src/settings/SSHKeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { useContext, useEffect, useState } from "react";
import { PageWithSubMenu } from "../components/PageWithSubMenu";
import getSettingsMenu from "./settings-menu";
import { PaymentContext } from "../payment-context";
import Modal from "../components/Modal";
import Alert from "../components/Alert";
import { Item, ItemField, ItemFieldContextMenu } from "../components/ItemsList";
import ConfirmationModal from "../components/ConfirmationModal";
import { SSHPublicKeyValue, UserSSHPublicKeyValue } from "@gitpod/gitpod-protocol";
import { getGitpodService } from "../service/service";

interface AddModalProps {
value: SSHPublicKeyValue;
onClose: () => void;
onSave: () => void;
}

interface DeleteModalProps {
value: KeyData;
onConfirm: () => void;
onClose: () => void;
}

type KeyData = SSHPublicKeyValue & { id?: string };

export function AddSSHKeyModal(props: AddModalProps) {
const [errorMsg, setErrorMsg] = useState("");

const [value, setValue] = useState({ ...props.value });
const update = (pev: Partial<SSHPublicKeyValue>) => {
setValue({ ...value, ...pev });
setErrorMsg("");
};

useEffect(() => {
setValue({ ...props.value });
setErrorMsg("");
}, [props.value]);

const save = async () => {
const tmp = SSHPublicKeyValue.validate(value);
if (tmp) {
setErrorMsg(tmp);
return false;
}
try {
await getGitpodService().server.addSSHPublicKey(value);
} catch (e) {
setErrorMsg(e.message);
return false;
}
props.onClose();
props.onSave();
return true;
};

return (
<Modal
title="New SSH Key"
buttons={
<button className="ml-2" onClick={save}>
Add Key
</button>
}
visible={true}
onClose={props.onClose}
onEnter={save}
>
<>
{errorMsg.length > 0 && (
<Alert type="error" className="mb-2">
{errorMsg}
</Alert>
)}
</>
<div className="text-gray-500 dark:text-gray-400 text-md">
Add an SSH key for secure access workspaces via SSH. Learn more
</div>
<Alert type="info" className="mt-2">
SSH key are used to connect securely to workspaces.{" "}
<a
href="https://www.gitpod.io/docs/configure/ssh#create-an-ssh-key"
target="gitpod-create-ssh-key-doc"
className="gp-link"
>
Learn how to create an SSH Key
</a>
</Alert>
<div className="mt-2">
<h4>Key</h4>
<textarea
autoFocus
style={{ height: "160px" }}
className="w-full resize-none"
value={value.key}
placeholder="Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'ssh-ed25519',
'[email protected]', or
'[email protected]'"
onChange={(v) => update({ key: v.target.value })}
/>
</div>
<div className="mt-2">
<h4>Title</h4>
<input
className="w-full"
type="text"
placeholder="e.g. laptop"
value={value.name}
onChange={(v) => {
update({ name: v.target.value });
}}
/>
</div>
</Modal>
);
}

export function DeleteSSHKeyModal(props: DeleteModalProps) {
const confirmDelete = async () => {
await getGitpodService().server.deleteSSHPublicKey(props.value.id!);
props.onConfirm();
props.onClose();
};
return (
<ConfirmationModal
title="Delete SSH Key"
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."
buttonText="Delete SSH Key"
onClose={props.onClose}
onConfirm={confirmDelete}
/>
);
}

export default function SSHKeys() {
const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext);

const [dataList, setDataList] = useState<UserSSHPublicKeyValue[]>([]);
const [currentData, setCurrentData] = useState<KeyData>({ name: "", key: "" });
const [showAddModal, setShowAddModal] = useState(false);
const [showDelModal, setShowDelModal] = useState(false);

const loadData = () => {
getGitpodService()
.server.getSSHPublicKeys()
.then((r) => setDataList(r));
};

useEffect(() => {
loadData();
}, []);

const addOne = () => {
setCurrentData({ name: "", key: "" });
setShowAddModal(true);
setShowDelModal(false);
};

const deleteOne = (value: UserSSHPublicKeyValue) => {
setCurrentData({ id: value.id, name: value.name, key: "" });
setShowAddModal(false);
setShowDelModal(true);
};

return (
<PageWithSubMenu
subMenu={getSettingsMenu({ showPaymentUI, showUsageBasedUI })}
title="SSH Keys"
subtitle="Connect securely to workspaces."
>
{showAddModal && (
<AddSSHKeyModal value={currentData} onSave={loadData} onClose={() => setShowAddModal(false)} />
)}
{showDelModal && (
<DeleteSSHKeyModal value={currentData} onConfirm={loadData} onClose={() => setShowDelModal(false)} />
)}
<div className="flex items-start sm:justify-between mb-2">
<div>
<h3>SSH Keys</h3>
<h2 className="text-gray-500">
Create and manage SSH keys.{" "}
<a href="https://www.gitpod.io/docs/configure/ssh" target="gitpod-ssh-doc" className="gp-link">
More details
</a>
</h2>
</div>
{dataList.length !== 0 ? (
<div className="mt-3 flex">
<button onClick={addOne} className="ml-2">
New SSH Key
</button>
</div>
) : null}
</div>
{dataList.length === 0 ? (
<div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full h-96">
<div className="pt-28 flex flex-col items-center w-120 m-auto">
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No SSH Keys</h3>
<div className="text-center pb-6 text-gray-500">
SSH keys allow you to establish a <b>secure connection</b> between your <b>computer</b> and{" "}
<b>workspaces</b>.
</div>
<button onClick={addOne}>New SSH Key</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
{dataList.map((key) => {
return (
<Item solid className="items-start">
<ItemField className="flex flex-col gap-y box-border overflow-hidden">
<p className="truncate text-gray-400 dark:text-gray-600">
{key.fingerprint
.toUpperCase()
.match(/.{1,2}/g)
?.join(":") ?? key.fingerprint}
</p>
<div className="truncate my-1 text-xl text-gray-800 dark:text-gray-100 font-semibold">
{key.name}
</div>
<p className="truncate mt-4">Added on {key.creationTime}</p>
{!!key.lastUsedTime && <p className="truncate">Last used on {key.lastUsedTime}</p>}
</ItemField>
<ItemFieldContextMenu
position="start"
menuEntries={[
{
title: "Delete",
customFontStyle:
"text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",
onClick: () => deleteOne(key),
},
]}
/>
</Item>
);
})}
</div>
)}
</PageWithSubMenu>
);
}
5 changes: 5 additions & 0 deletions components/dashboard/src/settings/settings-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
settingsPathPreferences,
settingsPathTeams,
settingsPathVariables,
settingsPathSSHKeys,
} from "./settings.routes";

export default function getSettingsMenu(params: { showPaymentUI?: boolean; showUsageBasedUI?: boolean }) {
Expand Down Expand Up @@ -50,6 +51,10 @@ export default function getSettingsMenu(params: { showPaymentUI?: boolean; showU
title: "Variables",
link: [settingsPathVariables],
},
{
title: "SSH Keys",
link: [settingsPathSSHKeys],
},
{
title: "Integrations",
link: [settingsPathIntegrations, "/access-control"],
Expand Down
2 changes: 2 additions & 0 deletions components/dashboard/src/settings/settings.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ export const settingsPathTeamsJoin = [settingsPathTeams, "join"].join("/");
export const settingsPathTeamsNew = [settingsPathTeams, "new"].join("/");

export const settingsPathVariables = "/variables";

export const settingsPathSSHKeys = "/keys";
Loading

0 comments on commit 56aa88c

Please sign in to comment.