* Copyright (C) 2021 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React, { useState } from "react";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { ClipboardCopy, ClipboardCopyVariant } from "@patternfly/react-core/dist/esm/components/ClipboardCopy/index.js";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { ActionGroup, Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js";
import {
Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
import { Switch } from "@patternfly/react-core/dist/esm/components/Switch/index.js";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { InfoCircleIcon } from '@patternfly/react-icons';
import * as credentials from "credentials";
import { ListingPanel } from 'cockpit-components-listing-panel.jsx';
import { ListingTable } from 'cockpit-components-table.jsx';
import { ModalError } from 'cockpit-components-inline-notification.jsx';
import { useEvent, useObject } from 'hooks';
import { WithDialogs, useDialogs, DialogResult } from "dialogs";
import { FileChooser } from "cockpit/react/FileChooser";
import "./credentials.scss";
const _ = cockpit.gettext;
const KeyDetails = ({ currentKey } : { currentKey: credentials.Key }) => {
return (
<DescriptionList className="pf-m-horizontal-on-sm">
<DescriptionListGroup>
<DescriptionListTerm>{_("Comment")}</DescriptionListTerm>
<DescriptionListDescription data-name="comment">{currentKey.comment}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{_("Type")}</DescriptionListTerm>
<DescriptionListDescription data-name="type">{currentKey.type}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{_("Fingerprint")}</DescriptionListTerm>
<DescriptionListDescription data-name="fingerprint">{currentKey.fingerprint}</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
);
};
const PublicKey = ({ currentKey } : { currentKey: credentials.Key }) => {
return (
<ClipboardCopy isReadOnly hoverTip={_("Copy")} clickTip={_("Copied")} variant={ClipboardCopyVariant.expansion}>
{currentKey.data.trim()}
</ClipboardCopy>
);
};
const KeyPassword = ({
currentKey,
keys,
setDialogError
} : {
currentKey: credentials.Key,
keys: credentials.Keys,
setDialogError: (msg: string | null) => void
}) => {
const [confirmPassword, setConfirmPassword] = useState('');
const [inProgress, setInProgress] = useState<boolean | undefined>(undefined);
const [newPassword, setNewPassword] = useState('');
const [oldPassword, setOldPassword] = useState('');
function changePassword() {
if (!currentKey || !currentKey.name)
return;
setInProgress(true);
setDialogError(null);
if (oldPassword === undefined || newPassword === undefined || confirmPassword === undefined)
setDialogError("Invalid password fields");
keys.change(currentKey.name, oldPassword, newPassword)
.then(() => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setInProgress(false);
})
.catch(ex => {
setDialogError(ex.message);
setInProgress(undefined);
});
}
const changePasswordBtn = (
<Button variant="primary"
id={(currentKey.name || currentKey.comment) + "-change-password"}
{... inProgress !== undefined ? { isDisabled: inProgress, isLoading: inProgress } : {} }
onClick={() => changePassword()}>{_("Change password")}</Button>
);
return (
<Form onSubmit={e => { e.preventDefault(); return false }} isHorizontal>
{inProgress === false && <HelperText>
<HelperTextItem variant="success">
{_("Password changed successfully")}
</HelperTextItem>
</HelperText>}
<FormGroup label={_("Password")} fieldId={(currentKey.name || currentKey.comment) + "-old-password"} type="password">
<TextInput type="password" id={(currentKey.name || currentKey.comment) + "-old-password"} value={oldPassword} onChange={(_event, value) => setOldPassword(value)} />
</FormGroup>
<FormGroup label={_("New password")} fieldId={(currentKey.name || currentKey.comment) + "-new-password"} type="password">
<TextInput type="password" id={(currentKey.name || currentKey.comment) + "-new-password"} value={newPassword} onChange={(_event, value) => setNewPassword(value)} />
</FormGroup>
<FormGroup label={_("Confirm password")} fieldId={(currentKey.name || currentKey.comment) + "-confirm-password"} type="password">
<TextInput type="password" id={(currentKey.name || currentKey.comment) + "-confirm-password"} value={confirmPassword} onChange={(_event, value) => setConfirmPassword(value)} />
</FormGroup>
<ActionGroup>
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
<Popover
aria-label={_("Password tip")}
bodyContent={_("Tip: Make your key password match your login password to automatically authenticate against other systems.")}
>
<button className="pf-v6-c-form__group-label-help ct-icon-info-circle"
onClick={e => e.preventDefault()}
type="button">
<InfoCircleIcon />
</button>
</Popover>
{changePasswordBtn}
</Flex>
</ActionGroup>
</Form>
);
};
const UnlockKey = ({
keyName,
keys,
onClose
} : {
keyName: string,
keys: credentials.Keys,
onClose: () => void
}) => {
const [password, setPassword] = useState("");
const [dialogError, setDialogError] = useState("");
function load_key() {
if (!keyName)
return;
keys.load(keyName, password)
.then(onClose)
.catch(ex => {
setDialogError(ex.message);
console.warn("loading key failed: ", ex.message);
});
}
return (
<Modal isOpen position="top" variant="small"
onClose={onClose}>
<ModalHeader title={cockpit.format(_("Unlock key $0"), keyName)} />
<ModalBody>
<Form onSubmit={e => { e.preventDefault(); return false }} isHorizontal>
{dialogError && <ModalError dialogError={dialogError} />}
<FormGroup label={_("Password")} fieldId={keyName + "-password"} type="password">
<TextInput type="password" id={keyName + "-password"} value={password} onChange={(_event, value) => setPassword(value)} />
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button variant="primary" id={keyName + "-unlock"} isDisabled={!keyName} onClick={load_key}>{_("Unlock")}</Button>
<Button variant='link' onClick={onClose}>{_("Cancel")}</Button>
</ModalFooter>
</Modal>
);
};
const AddKeyButton = ({
callback
} : {
callback: (path: string) => Promise<void>,
}) => {
const Dialogs = useDialogs();
return (
<Button
variant='secondary'
id="ssh-file-add-custom"
onClick={
async () => {
const user = await cockpit.user();
Dialogs.show(
<FileChooser
title={_("Add private SSH key")}
action={callback}
actionLabel={_("Add key")}
path={user.home + "/.ssh"}
/>
);
}
}
>
{_("Add key")}
</Button>
);
};
export const CredentialsModal = ({
dialogResult
} : {
dialogResult: DialogResult<void>
}) => {
const keys = useObject(() => credentials.keys_instance(), null, []);
const [dialogError, setDialogError] = useState();
const [unlockKey, setUnlockKey] = useState<string | undefined>();
useEvent(keys as unknown as cockpit.EventSource<cockpit.EventMap>, "changed");
if (!keys)
return null;
function onToggleKey(id: string, enable: boolean) {
const key = keys.items[id];
if (!key || !key.name)
return;
if (enable && !key.loaded) {
setUnlockKey(key.name);
} else if (!enable && key.loaded) {
keys.unload(key).catch(ex => setDialogError(ex.message));
}
}
const addCustomKey = async (newKeyPath: string): Promise<void> => {
try {
await keys.load(newKeyPath, "");
} catch (ex) {
if (!(ex instanceof credentials.KeyLoadError) || !ex.sent_password)
throw ex;
else
setUnlockKey(newKeyPath);
}
};
return (
<>
<Modal isOpen position="top" variant="medium"
onClose={() => dialogResult.resolve()}
id="credentials-modal"
>
<ModalHeader title={_("SSH keys")} />
<ModalBody>
<Stack hasGutter>
{dialogError && <ModalError dialogError={dialogError} />}
<Flex justifyContent={{ default: 'justifyContentSpaceBetween' }}>
<FlexItem>{_("Use the following keys to authenticate against other systems")}</FlexItem>
<WithDialogs>
<AddKeyButton callback={addCustomKey} />
</WithDialogs>
</Flex>
<ListingTable
aria-label={ _("SSH keys") }
gridBreakPoint=''
id="credential-keys"
showHeader={false}
variant="compact"
columns={ [
{ title: _("Name"), header: true },
{ title: _("Toggle") },
] }
rows={ Object.keys(keys.items).map((currentKeyId, index) => {
const currentKey = keys.items[currentKeyId] || { name: 'test' };
const tabRenderers = [
{
data: { currentKey },
name: _("Details"),
renderer: KeyDetails,
},
{
data: { currentKey },
name: _("Public key"),
renderer: PublicKey,
},
{
data: { currentKey, keys, setDialogError },
name: _("Password"),
renderer: KeyPassword,
},
];
const expandedContent = (
<ListingPanel tabRenderers={tabRenderers} />
);
return ({
columns: [
{
title: currentKey.name || currentKey.comment,
},
{
title: <Switch aria-label={_("Use key")}
isChecked={!!currentKey.loaded}
key={"switch-" + index}
onChange={(_event, value) => onToggleKey(currentKeyId, value)} />,
props: {
isActionCell: true
}
}
],
expandedContent,
props: { key: currentKey.fingerprint, 'data-name': currentKey.name || currentKey.comment, 'data-loaded': !!currentKey.loaded },
});
})} />
</Stack>
</ModalBody>
<ModalFooter>
<Button variant='secondary' onClick={() => dialogResult.resolve()}>{_("Close")}</Button>
</ModalFooter>
</Modal>
{unlockKey && <UnlockKey keyName={unlockKey} keys={keys} onClose={() => setUnlockKey(undefined)} />}
</>
);
};