Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 123 : import cockpit from "cockpit";
7 123 : import React, { useState } from "react";
8 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
9 : import { ClipboardCopy, ClipboardCopyVariant } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
10 : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
11 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
12 : import { ActionGroup, Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
13 : import { Grid, GridItem } from "@patternfly/react-core/dist/esm/layouts/Grid/index.js";
14 : import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
15 : import {
16 : Modal, ModalBody, ModalFooter, ModalHeader
17 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
18 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
19 : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
20 : import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
21 : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
22 : import { InfoCircleIcon } from '@patternfly/react-icons';
23 :
24 : import * as credentials from "credentials";
25 : import { FileAutoComplete } from "cockpit-components-file-autocomplete.jsx";
26 : import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
27 : import { ListingTable } from 'cockpit-components-table.jsx';
28 : import { ModalError } from 'cockpit-components-inline-notification.jsx';
29 : import { useEvent, useObject } from 'hooks';
30 : import { DialogResult } from "dialogs";
31 :
32 : import "./credentials.scss";
33 :
34 123 : const _ = cockpit.gettext;
35 :
36 1 : const AddNewKey = ({
37 1 : keys,
38 1 : unlockKey,
39 1 : onClose
40 1 : } : {
41 : keys: credentials.Keys,
42 : unlockKey: (name: string) => void,
43 : onClose: () => void
44 1 : }) => {
45 1 : const [addNewKeyLoading, setAddNewKeyLoading] = useState(false);
46 1 : const [newKeyPath, setNewKeyPath] = useState("");
47 1 : const [newKeyPathError, setNewKeyPathError] = useState("");
48 :
49 1 : const addCustomKey = () => {
50 1 : setAddNewKeyLoading(true);
51 1 : keys.load(newKeyPath, "")
52 1 : .then(onClose)
53 1 : .catch(ex => {
54 1 : if (!(ex instanceof credentials.KeyLoadError) || !ex.sent_password)
55 1 : setNewKeyPathError(ex.message);
56 : else
57 1 : unlockKey(newKeyPath);
58 1 : })
59 1 : .finally(() => setAddNewKeyLoading(false));
60 1 : };
61 :
62 1 : return (
63 1 : <Grid hasGutter>
64 1 : <GridItem span={9} id="ssh-file-add-key">
65 1 : <FileAutoComplete onChange={setNewKeyPath}
66 1 : placeholder={_("Path to file")}
67 1 : superuser="try" />
68 1 : {newKeyPathError && <HelperText className="pf-v6-c-form__helper-text">
69 1 : <HelperTextItem variant="error">{newKeyPathError}</HelperTextItem>
70 1 : </HelperText>}
71 1 : </GridItem>
72 1 : <GridItem span={1}>
73 1 : <Button id="ssh-file-add"
74 1 : isDisabled={addNewKeyLoading || !newKeyPath}
75 1 : isLoading={addNewKeyLoading}
76 1 : onClick={addCustomKey}
77 1 : variant="secondary">
78 1 : {_("Add")}
79 1 : </Button>
80 1 : </GridItem>
81 1 : </Grid>
82 : );
83 1 : };
84 :
85 2 : const KeyDetails = ({ currentKey } : { currentKey: credentials.Key }) => {
86 2 : return (
87 2 : <DescriptionList className="pf-m-horizontal-on-sm">
88 2 : <DescriptionListGroup>
89 2 : <DescriptionListTerm>{_("Comment")}</DescriptionListTerm>
90 2 : <DescriptionListDescription data-name="comment">{currentKey.comment}</DescriptionListDescription>
91 2 : </DescriptionListGroup>
92 2 : <DescriptionListGroup>
93 2 : <DescriptionListTerm>{_("Type")}</DescriptionListTerm>
94 2 : <DescriptionListDescription data-name="type">{currentKey.type}</DescriptionListDescription>
95 2 : </DescriptionListGroup>
96 2 : <DescriptionListGroup>
97 2 : <DescriptionListTerm>{_("Fingerprint")}</DescriptionListTerm>
98 2 : <DescriptionListDescription data-name="fingerprint">{currentKey.fingerprint}</DescriptionListDescription>
99 2 : </DescriptionListGroup>
100 2 : </DescriptionList>
101 : );
102 2 : };
103 :
104 1 : const PublicKey = ({ currentKey } : { currentKey: credentials.Key }) => {
105 1 : return (
106 1 : <ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} variant={ClipboardCopyVariant.expansion}>
107 1 : {currentKey.data.trim()}
108 1 : </ClipboardCopy>
109 : );
110 1 : };
111 :
112 0 : const KeyPassword = ({
113 0 : currentKey,
114 0 : keys,
115 0 : setDialogError
116 0 : } : {
117 : currentKey: credentials.Key,
118 : keys: credentials.Keys,
119 : setDialogError: (msg: string | null) => void
120 0 : }) => {
121 0 : const [confirmPassword, setConfirmPassword] = useState('');
122 0 : const [inProgress, setInProgress] = useState<boolean | undefined>(undefined);
123 0 : const [newPassword, setNewPassword] = useState('');
124 0 : const [oldPassword, setOldPassword] = useState('');
125 :
126 0 : function changePassword() {
127 0 : if (!currentKey || !currentKey.name)
128 0 : return;
129 :
130 0 : setInProgress(true);
131 0 : setDialogError(null);
132 :
133 0 : if (oldPassword === undefined || newPassword === undefined || confirmPassword === undefined)
134 0 : setDialogError("Invalid password fields");
135 :
136 0 : keys.change(currentKey.name, oldPassword, newPassword)
137 0 : .then(() => {
138 0 : setOldPassword('');
139 0 : setNewPassword('');
140 0 : setConfirmPassword('');
141 0 : setInProgress(false);
142 0 : })
143 0 : .catch(ex => {
144 0 : setDialogError(ex.message);
145 0 : setInProgress(undefined);
146 0 : });
147 0 : }
148 :
149 0 : const changePasswordBtn = (
150 0 : <Button variant="primary"
151 0 : id={(currentKey.name || currentKey.comment) + "-change-password"}
152 0 : {... inProgress !== undefined ? { isDisabled: inProgress, isLoading: inProgress } : {} }
153 0 : onClick={() => changePassword()}>{_("Change password")}</Button>
154 : );
155 :
156 0 : return (
157 0 : <Form onSubmit={e => { e.preventDefault(); return false }} isHorizontal>
158 0 : {inProgress === false && <HelperText>
159 0 : <HelperTextItem variant="success">
160 0 : {_("Password changed successfully")}
161 0 : </HelperTextItem>
162 0 : </HelperText>}
163 0 : <FormGroup label={_("Password")} fieldId={(currentKey.name || currentKey.comment) + "-old-password"} type="password">
164 0 : <TextInput type="password" id={(currentKey.name || currentKey.comment) + "-old-password"} value={oldPassword} onChange={(_event, value) => setOldPassword(value)} />
165 0 : </FormGroup>
166 0 : <FormGroup label={_("New password")} fieldId={(currentKey.name || currentKey.comment) + "-new-password"} type="password">
167 0 : <TextInput type="password" id={(currentKey.name || currentKey.comment) + "-new-password"} value={newPassword} onChange={(_event, value) => setNewPassword(value)} />
168 0 : </FormGroup>
169 0 : <FormGroup label={_("Confirm password")} fieldId={(currentKey.name || currentKey.comment) + "-confirm-password"} type="password">
170 0 : <TextInput type="password" id={(currentKey.name || currentKey.comment) + "-confirm-password"} value={confirmPassword} onChange={(_event, value) => setConfirmPassword(value)} />
171 0 : </FormGroup>
172 0 : <ActionGroup>
173 0 : <Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
174 0 : <Popover
175 0 : aria-label={_("Password tip")}
176 0 : bodyContent={_("Tip: Make your key password match your login password to automatically authenticate against other systems.")}
177 : >
178 0 : <button className="pf-v6-c-form__group-label-help ct-icon-info-circle"
179 0 : onClick={e => e.preventDefault()}
180 0 : type="button">
181 0 : <InfoCircleIcon />
182 0 : </button>
183 0 : </Popover>
184 0 : {changePasswordBtn}
185 0 : </Flex>
186 0 : </ActionGroup>
187 0 : </Form>
188 : );
189 0 : };
190 :
191 1 : const UnlockKey = ({
192 1 : keyName,
193 1 : keys,
194 1 : onClose
195 1 : } : {
196 : keyName: string,
197 : keys: credentials.Keys,
198 : onClose: () => void
199 1 : }) => {
200 1 : const [password, setPassword] = useState("");
201 1 : const [dialogError, setDialogError] = useState("");
202 :
203 1 : function load_key() {
204 1 : if (!keyName)
205 1 : return;
206 :
207 1 : keys.load(keyName, password)
208 1 : .then(onClose)
209 0 : .catch(ex => {
210 0 : setDialogError(ex.message);
211 0 : console.warn("loading key failed: ", ex.message);
212 0 : });
213 1 : }
214 :
215 1 : return (
216 1 : <Modal isOpen position="top" variant="small"
217 1 : onClose={onClose}>
218 1 : <ModalHeader title={cockpit.format(_("Unlock key $0"), keyName)} />
219 1 : <ModalBody>
220 0 : <Form onSubmit={e => { e.preventDefault(); return false }} isHorizontal>
221 0 : {dialogError && <ModalError dialogError={dialogError} />}
222 1 : <FormGroup label={_("Password")} fieldId={keyName + "-password"} type="password">
223 1 : <TextInput type="password" id={keyName + "-password"} value={password} onChange={(_event, value) => setPassword(value)} />
224 1 : </FormGroup>
225 1 : </Form>
226 1 : </ModalBody>
227 1 : <ModalFooter>
228 1 : <Button variant="primary" id={keyName + "-unlock"} isDisabled={!keyName} onClick={load_key}>{_("Unlock")}</Button>
229 1 : <Button variant='link' onClick={onClose}>{_("Cancel")}</Button>
230 1 : </ModalFooter>
231 1 : </Modal>
232 : );
233 1 : };
234 :
235 2 : export const CredentialsModal = ({
236 2 : dialogResult
237 2 : } : {
238 : dialogResult: DialogResult<void>
239 2 : }) => {
240 2 : const keys = useObject(() => credentials.keys_instance(), null, []);
241 2 : const [addNewKey, setAddNewKey] = useState(false);
242 2 : const [dialogError, setDialogError] = useState();
243 2 : const [unlockKey, setUnlockKey] = useState<string | undefined>();
244 :
245 2 : useEvent(keys as unknown as cockpit.EventSource<cockpit.EventMap>, "changed");
246 :
247 2 : if (!keys)
248 0 : return null;
249 :
250 2 : function onToggleKey(id: string, enable: boolean) {
251 2 : const key = keys.items[id];
252 :
253 2 : if (!key || !key.name)
254 2 : return;
255 :
256 : /* Key needs to be loaded, show load UI */
257 0 : if (enable && !key.loaded) {
258 0 : setUnlockKey(key.name);
259 : /* Key needs to be unloaded, do that directly */
260 0 : } else if (!enable && key.loaded) {
261 0 : keys.unload(key).catch(ex => setDialogError(ex.message));
262 1 : }
263 2 : }
264 :
265 2 : return (
266 2 : <>
267 2 : <Modal isOpen position="top" variant="medium"
268 2 : onClose={() => dialogResult.resolve()}
269 2 : id="credentials-modal"
270 : >
271 2 : <ModalHeader title={_("SSH keys")} />
272 2 : <ModalBody>
273 2 : <Stack hasGutter>
274 0 : {dialogError && <ModalError dialogError={dialogError} />}
275 2 : <Flex justifyContent={{ default: 'justifyContentSpaceBetween' }}>
276 2 : <FlexItem>{_("Use the following keys to authenticate against other systems")}</FlexItem>
277 2 : <Button variant='secondary'
278 2 : id="ssh-file-add-custom"
279 1 : onClick={() => setAddNewKey(true)}>
280 2 : {_("Add key")}
281 2 : </Button>
282 2 : </Flex>
283 1 : {addNewKey && <AddNewKey keys={keys} unlockKey={setUnlockKey} onClose={() => setAddNewKey(false)} />}
284 2 : <ListingTable
285 2 : aria-label={ _("SSH keys") }
286 2 : gridBreakPoint=''
287 2 : id="credential-keys"
288 2 : showHeader={false}
289 2 : variant="compact"
290 2 : columns={ [
291 2 : { title: _("Name"), header: true },
292 2 : { title: _("Toggle") },
293 2 : ] }
294 2 : rows={ Object.keys(keys.items).map((currentKeyId, index) => {
295 0 : const currentKey = keys.items[currentKeyId] || { name: 'test' };
296 2 : const tabRenderers = [
297 2 : {
298 2 : data: { currentKey },
299 2 : name: _("Details"),
300 2 : renderer: KeyDetails,
301 2 : },
302 2 : {
303 2 : data: { currentKey },
304 2 : name: _("Public key"),
305 2 : renderer: PublicKey,
306 2 : },
307 2 : {
308 2 : data: { currentKey, keys, setDialogError },
309 2 : name: _("Password"),
310 2 : renderer: KeyPassword,
311 2 : },
312 2 : ];
313 2 : const expandedContent = (
314 2 : <ListingPanel tabRenderers={tabRenderers} />
315 : );
316 :
317 2 : return ({
318 2 : columns: [
319 2 : {
320 1 : title: currentKey.name || currentKey.comment,
321 2 : },
322 2 : {
323 2 : title: <Switch aria-label={_("Use key")}
324 2 : isChecked={!!currentKey.loaded}
325 2 : key={"switch-" + index}
326 2 : onChange={(_event, value) => onToggleKey(currentKeyId, value)} />,
327 2 : }
328 2 : ],
329 2 : expandedContent,
330 1 : props: { key: currentKey.fingerprint, 'data-name': currentKey.name || currentKey.comment, 'data-loaded': !!currentKey.loaded },
331 2 : });
332 2 : })} />
333 2 : </Stack>
334 2 : </ModalBody>
335 2 : <ModalFooter>
336 0 : <Button variant='secondary' onClick={() => dialogResult.resolve()}>{_("Close")}</Button>
337 2 : </ModalFooter>
338 2 : </Modal>
339 1 : {unlockKey && <UnlockKey keyName={unlockKey} keys={keys} onClose={() => { setUnlockKey(undefined); setAddNewKey(false) }} />}
340 2 : </>
341 : );
342 2 : };
|