Skip to content
This repository has been archived by the owner on Jun 6, 2024. It is now read-only.

Add ssh public keys on user-profile page #5223

Merged
merged 8 commits into from
Feb 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 26 additions & 41 deletions src/webportal/src/app/job-submission/components/tools/job-ssh.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ import { cloneDeep, isEmpty, isNil } from 'lodash';
import { TooltipIcon } from '../controls/tooltip-icon';
import {
PAI_PLUGIN,
USERSSH_TYPE_OPTIONS,
SSH_KEY_BITS,
PROTOCOL_TOOLTIPS,
} from '../../utils/constants';
import { Hint } from '../sidebar/hint';
import { SSHPlugin } from '../../models/plugin/ssh-plugin';
import SSHGenerator from './ssh-generator';

import {
DefaultButton,
Dropdown,
FontWeights,
Toggle,
Stack,
Expand Down Expand Up @@ -63,16 +62,6 @@ export const JobSSH = ({ extras, onExtrasChange }) => {
[extras],
);

const _onUsersshTypeChange = useCallback(
(_, item) => {
_onChangeExtras('userssh', {
type: item.key,
value: '',
});
},
[extras, _onChangeExtras],
);

const _onUsersshValueChange = useCallback(
e => {
_onChangeExtras('userssh', {
Expand Down Expand Up @@ -128,36 +117,32 @@ export const JobSSH = ({ extras, onExtrasChange }) => {
onChange={_onUsersshEnable}
/>
{!isEmpty(sshPlugin.userssh) && (
<Stack horizontal gap='l1'>
<Dropdown
placeholder='Select user ssh key type...'
options={USERSSH_TYPE_OPTIONS}
onChange={_onUsersshTypeChange}
selectedKey={sshPlugin.userssh.type}
disabled={Object.keys(USERSSH_TYPE_OPTIONS).length <= 1}
/>
<TextField
placeholder='Enter ssh public key'
disabled={sshPlugin.userssh.type === 'none'}
errorMessage={
isEmpty(sshPlugin.getUserSshValue())
? 'Please Enter Valid SSH public key'
: null
}
onChange={_onUsersshValueChange}
value={sshPlugin.getUserSshValue()}
/>
<DefaultButton onClick={ev => openSshGenerator(ev)}>
SSH Key Generator
</DefaultButton>
{sshGenerator.isOpen && (
<SSHGenerator
isOpen={sshGenerator.isOpen}
bits={sshGenerator.bits}
hide={hideSshGenerator}
onSshKeysChange={_onSshKeysGenerated}
<Stack gap='l1'>
<Hint>
Your pre-defined SSH public keys on the{' '}
<a href='/user-profile.html'>User Profile</a> page will be set
automatically.
</Hint>
<Stack horizontal gap='l1'>
<TextField
Lable='Add additional SSH public key'
placeholder='Additional SSH public key'
disabled={sshPlugin.userssh.type === 'none'}
onChange={_onUsersshValueChange}
value={sshPlugin.getUserSshValue()}
/>
)}
<DefaultButton onClick={ev => openSshGenerator(ev)}>
Generator
</DefaultButton>
{sshGenerator.isOpen && (
<SSHGenerator
isOpen={sshGenerator.isOpen}
bits={sshGenerator.bits}
hide={hideSshGenerator}
onSshKeysChange={_onSshKeysGenerated}
/>
)}
</Stack>
</Stack>
)}
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ const runtimePluginSchema = Joi.object().keys({
sshbarrierTimeout: Joi.number(),
userssh: Joi.object().keys({
type: Joi.string(),
value: Joi.string(),
value: Joi.string()
.allow(null)
.allow('')
.optional(),
}),
})
.required(),
Expand Down
20 changes: 20 additions & 0 deletions src/webportal/src/app/user/fabric/conn.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,26 @@ export const getUserRequest = async username => {
});
};

export const updateUserRequest = async (username, sshMessage) => {
const url = `${config.restServerUri}/api/v2/users/me`;
const token = checkToken();
return fetchWrapper(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
username: username,
extension: {
sshKeys: sshMessage,
},
},
patch: true,
}),
});
};

export const getTokenRequest = async () => {
return wrapper(() => client.token.getTokens());
};
Expand Down
62 changes: 62 additions & 0 deletions src/webportal/src/app/user/fabric/user-profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ import {
listStorageDetailRequest,
getGroupsRequest,
updateBoundedClustersRequest,
updateUserRequest,
} from './conn';

import t from '../../components/tachyons.scss';
import { VirtualClusterDetailsList } from '../../home/home/virtual-cluster-statistics';
import TokenList from './user-profile/token-list';
import SSHlist from './user-profile/ssh-list';
import UserProfileHeader from './user-profile/header';
import StorageList from './user-profile/storage-list';
import BoundedClusterDialog from './user-profile/bounded-cluster-dialog';
import SSHListDialog from './user-profile/ssh-list-dialog';
import BoundedClusterList from './user-profile/bounded-cluster-list';

const UserProfileCard = ({ title, children, headerButton }) => {
Expand Down Expand Up @@ -70,7 +73,11 @@ const UserProfile = () => {
const [showBoundedClusterDialog, setShowBoundedClusterDialog] = useState(
false,
);
const [showAddSSHpublicKeysDialog, setShowAddSSHpublicKeysDialog] = useState(
false,
);
const [processing, setProcessing] = useState(false);
const [sshProcessing, setSSHProcessing] = useState(false);

useEffect(() => {
const fetchData = async () => {
Expand Down Expand Up @@ -139,6 +146,37 @@ const UserProfile = () => {
});
});

// click `add public ssh keys button` -> open dialog
const onAddPublicKeys = useCallback(async sshPublicKeys => {
setSSHProcessing(true);
let updatedSSHPublickeys = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please validate here: the user cannot add ssh keys with duplicate titles. (Title should be unique for one user)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

if (userInfo.extension.sshKeys) {
updatedSSHPublickeys = cloneDeep(userInfo.extension.sshKeys);
}
updatedSSHPublickeys.push({
title: sshPublicKeys.title,
value: sshPublicKeys.value,
time: sshPublicKeys.time,
});
await updateUserRequest(userInfo.username, updatedSSHPublickeys);
const updatedUserInfo = await getUserRequest(userInfo.username);
setUserInfo(updatedUserInfo);
setSSHProcessing(false);
});

const onDeleteSSHkeys = useCallback(async sshPublicKeys => {
let updatedSSHPublickeys = [];
if (userInfo.extension.sshKeys) {
updatedSSHPublickeys = cloneDeep(userInfo.extension.sshKeys);
}
updatedSSHPublickeys = updatedSSHPublickeys.filter(
item => item.title !== sshPublicKeys.title,
);
await updateUserRequest(userInfo.username, updatedSSHPublickeys);
const updatedUserInfo = await getUserRequest(userInfo.username);
setUserInfo(updatedUserInfo);
});

const onRevokeToken = useCallback(async token => {
await revokeTokenRequest(token);
await getTokenRequest().then(res => setTokens(res.tokens));
Expand Down Expand Up @@ -197,6 +235,30 @@ const UserProfile = () => {
onEditPassword={onEditPassword}
/>
</Card>
<UserProfileCard
title='SSH Public Keys'
headerButton={
<DefaultButton
onClick={() => setShowAddSSHpublicKeysDialog(true)}
disabled={sshProcessing}
>
Add SSH Public Keys
</DefaultButton>
}
>
<SSHlist
sshKeys={userInfo.extension.sshKeys}
onDeleteSSHkeys={onDeleteSSHkeys}
/>
{/* dialog for add public ssh keys */}
{showAddSSHpublicKeysDialog && (
<SSHListDialog
sshKeys={userInfo.extension.sshKeys}
onDismiss={() => setShowAddSSHpublicKeysDialog(false)}
onAddPublickeys={onAddPublicKeys}
/>
)}
</UserProfileCard>
<UserProfileCard
title='Tokens'
headerButton={
Expand Down
148 changes: 148 additions & 0 deletions src/webportal/src/app/user/fabric/user-profile/ssh-list-dialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { isEmpty } from 'lodash';
import {
DefaultButton,
PrimaryButton,
DialogType,
Dialog,
DialogFooter,
TextField,
} from 'office-ui-fabric-react';

import t from '../../../components/tachyons.scss';

const SSHListDialog = ({ sshKeys, onDismiss, onAddPublickeys }) => {
const [error, setError] = useState('');
const [inputTitleError, setInputTitleError] = useState('');
const [inputValueError, setInputValueError] = useState('');
const [processing, setProcessing] = useState(false);
const [title, setTitle] = useState('');
const [value, setValue] = useState('');

const onAddAsync = async () => {
let surefireTitle = false;
let surefireValue = false;

if (title.trim() === '') {
setInputTitleError('Please input title');
} else if (
sshKeys !== undefined &&
sshKeys.filter(item => item.title === title.trim()).length > 0
) {
setInputTitleError('This title already exists, please re-input');
} else {
surefireTitle = true;
}
if (
value.trim() === '' ||
!value.trim().match(/^ssh-rsa AAAA[0-9A-Za-z+/]+[=]{0,3}.*$/)
) {
setInputValueError(
'Please input correct SSH Public key, it should be starting with ssh-rsa.',
);
} else {
Copy link
Contributor

@hzy46 hzy46 Feb 3, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please validate the value matches SSH key's format.

You can use this regex: /^ssh-rsa AAAA[0-9A-Za-z+/]+[=]{0,3}.*$/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

surefireValue = true;
}
if (surefireTitle && surefireValue) {
setProcessing(true);
try {
await onAddPublickeys({
title: title.trim(),
value: value.trim(),
time: new Date().getTime(),
});
} catch (error) {
setError(error.message);
} finally {
setProcessing(false);
onDismiss();
}
}
};

return (
<div>
<Dialog
hidden={false}
onDismiss={onDismiss}
dialogContentProps={{
type: DialogType.normal,
title: 'Add SSH Public Keys',
}}
modalProps={{
isBlocking: true,
}}
minWidth={600}
>
<div>
<div className={t.mt1}>
<TextField
label='Title (Please give the SSH key a name):'
required={true}
errorMessage={inputTitleError}
onChange={e => {
setTitle(e.target.value);
setInputTitleError(null);
}}
validateOnFocusOut={true}
/>
</div>
<div className={t.mt1}>
<TextField
label='Value (SSH Public key, starts with ssh-rsa):'
required={true}
errorMessage={inputValueError}
onChange={e => {
setValue(e.target.value);
setInputValueError(null);
}}
multiline
rows={5}
validateOnFocusOut={true}
/>
</div>
</div>
<DialogFooter>
<PrimaryButton
onClick={onAddAsync}
disabled={processing}
text='Add'
/>
<DefaultButton
onClick={onDismiss}
disabled={processing}
text='Cancel'
/>
</DialogFooter>
</Dialog>
<Dialog
hidden={isEmpty(error)}
onDismiss={() => setError('')}
dialogContentProps={{
type: DialogType.normal,
title: 'Error',
subText: error,
}}
modalProps={{
isBlocking: true,
}}
>
<DialogFooter>
<DefaultButton onClick={() => setError('')}>OK</DefaultButton>
</DialogFooter>
</Dialog>
</div>
);
};

SSHListDialog.propTypes = {
sshKeys: PropTypes.array.isRequired,
onDismiss: PropTypes.func.isRequired,
onAddPublickeys: PropTypes.func.isRequired,
};

export default SSHListDialog;
Loading