LCOV - code coverage report
Current view: top level - pkg/shell - credentials.tsx Coverage Total Hit
Test: cockpit Lines: 69.7 % 264 184
Test Date: 2026-07-02 14:11:36

            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 : };
        

Generated by: LCOV version 2.0-1