645 lines
24 KiB
JavaScript
645 lines
24 KiB
JavaScript
/*
|
|
* This file is part of Cockpit.
|
|
*
|
|
* Copyright (C) 2018 Red Hat, Inc.
|
|
*
|
|
* Cockpit is free software; you can redistribute it and/or modify it
|
|
* under the terms of the GNU Lesser General Public License as published by
|
|
* the Free Software Foundation; either version 2.1 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Cockpit is distributed in the hope that it will be useful, but
|
|
* WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
* Lesser General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Lesser General Public License
|
|
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import cockpit from "cockpit";
|
|
import React from "react";
|
|
|
|
import {
|
|
Card, CardBody, CardTitle, CardHeader, CardActions,
|
|
Checkbox, ClipboardCopy,
|
|
Form, FormGroup,
|
|
DataListItem, DataListItemRow, DataListItemCells, DataListCell, DataList,
|
|
Text, TextVariants, TextInput as TextInputPF, Stack,
|
|
} from "@patternfly/react-core";
|
|
import { EditIcon, MinusIcon, PlusIcon } from "@patternfly/react-icons";
|
|
|
|
import sha1 from "js-sha1";
|
|
import sha256 from "js-sha256";
|
|
import stable_stringify from "json-stable-stringify-without-jsonify";
|
|
|
|
import {
|
|
dialog_open,
|
|
SelectOneRadio, TextInput, PassInput, Skip
|
|
} from "./dialog.jsx";
|
|
import { array_find, decode_filename, block_name } from "./utils.js";
|
|
import { fmt_to_fragments } from "utils.jsx";
|
|
import { StorageButton } from "./storage-controls.jsx";
|
|
|
|
import clevis_luks_passphrase_sh from "raw-loader!./clevis-luks-passphrase.sh";
|
|
|
|
const _ = cockpit.gettext;
|
|
|
|
/* Tang advertisement utilities
|
|
*/
|
|
|
|
function get_tang_adv(url) {
|
|
return cockpit.spawn(["curl", "-sSf", url + "/adv"], { err: "message" })
|
|
.then(JSON.parse)
|
|
.catch(error => {
|
|
return cockpit.reject(error.toString().replace(/^curl: \([0-9]+\) /, ""));
|
|
});
|
|
}
|
|
|
|
function tang_adv_payload(adv) {
|
|
return JSON.parse(cockpit.utf8_decoder().decode(cockpit.base64_decode(adv.payload)));
|
|
}
|
|
|
|
function jwk_b64_encode(bytes) {
|
|
// Use the urlsafe character set, and strip the padding.
|
|
return cockpit.base64_encode(bytes).replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, '');
|
|
}
|
|
|
|
function compute_thp(jwk) {
|
|
const REQUIRED_ATTRS = {
|
|
RSA: ['kty', 'p', 'd', 'q', 'dp', 'dq', 'qi', 'oth'],
|
|
EC: ['kty', 'crv', 'x', 'y'],
|
|
oct: ['kty', 'k'],
|
|
};
|
|
|
|
if (!jwk.kty)
|
|
return "(no key type attribute=";
|
|
if (!REQUIRED_ATTRS[jwk.kty])
|
|
return cockpit.format("(unknown keytype $0)", jwk.kty);
|
|
|
|
const req = REQUIRED_ATTRS[jwk.kty];
|
|
const norm = { };
|
|
req.forEach(k => { if (k in jwk) norm[k] = jwk[k]; });
|
|
return {
|
|
sha256: jwk_b64_encode(sha256.digest(stable_stringify(norm))),
|
|
sha1: jwk_b64_encode(sha1.digest(stable_stringify(norm)))
|
|
};
|
|
}
|
|
|
|
function compute_sigkey_thps(adv) {
|
|
function is_signing_key(jwk) {
|
|
if (!jwk.use && !jwk.key_ops)
|
|
return true;
|
|
if (jwk.use == "sig")
|
|
return true;
|
|
if (jwk.key_ops && jwk.key_ops.indexOf("verify") >= 0)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
return adv.keys.filter(is_signing_key).map(compute_thp);
|
|
}
|
|
|
|
/* Clevis operations
|
|
*/
|
|
|
|
function clevis_add(block, pin, cfg, passphrase) {
|
|
const dev = decode_filename(block.Device);
|
|
return cockpit.spawn(["clevis", "luks", "bind", "-f", "-k", "-", "-d", dev, pin, JSON.stringify(cfg)],
|
|
{ superuser: true, err: "message" }).input(passphrase);
|
|
}
|
|
|
|
function clevis_remove(block, key) {
|
|
// clevis-luks-unbind needs a tty on stdin for some reason.
|
|
return cockpit.spawn(["clevis", "luks", "unbind", "-d", decode_filename(block.Device), "-s", key.slot, "-f"],
|
|
{ superuser: true, pty: true, err: "message" });
|
|
}
|
|
|
|
export function clevis_recover_passphrase(block, just_type) {
|
|
const dev = decode_filename(block.Device);
|
|
const args = [];
|
|
if (just_type)
|
|
args.push("--type");
|
|
args.push(dev);
|
|
return cockpit.script(clevis_luks_passphrase_sh, args,
|
|
{ superuser: true, err: "message" })
|
|
.then(output => output.trim());
|
|
}
|
|
|
|
function clevis_unlock(block) {
|
|
const dev = decode_filename(block.Device);
|
|
const clear_dev = "luks-" + block.IdUUID;
|
|
return cockpit.spawn(["clevis", "luks", "unlock", "-d", dev, "-n", clear_dev],
|
|
{ superuser: true });
|
|
}
|
|
|
|
export function unlock_with_type(client, block, passphrase, passphrase_type) {
|
|
const crypto = client.blocks_crypto[block.path];
|
|
if (passphrase)
|
|
return crypto.Unlock(passphrase, {});
|
|
else if (passphrase_type == "stored")
|
|
return crypto.Unlock("", {});
|
|
else if (passphrase_type == "clevis")
|
|
return clevis_unlock(block);
|
|
else {
|
|
// This should always be caught and should never show up in the UI
|
|
return Promise.reject(new Error("No passphrase"));
|
|
}
|
|
}
|
|
|
|
/* Passphrase operations
|
|
*/
|
|
|
|
function passphrase_add(block, new_passphrase, old_passphrase) {
|
|
const dev = decode_filename(block.Device);
|
|
return cockpit.spawn(["cryptsetup", "luksAddKey", dev],
|
|
{ superuser: true, err: "message" }).input(old_passphrase + "\n" + new_passphrase);
|
|
}
|
|
|
|
function passphrase_change(block, key, new_passphrase, old_passphrase) {
|
|
const dev = decode_filename(block.Device);
|
|
return cockpit.spawn(["cryptsetup", "luksChangeKey", dev, "--key-slot", key.slot.toString()],
|
|
{ superuser: true, err: "message" }).input(old_passphrase + "\n" + new_passphrase + "\n");
|
|
}
|
|
|
|
function slot_remove(block, slot, passphrase) {
|
|
const dev = decode_filename(block.Device);
|
|
const opts = { superuser: true, err: "message" };
|
|
const cmd = ["cryptsetup", "luksKillSlot", dev, slot.toString()];
|
|
if (passphrase === false) {
|
|
cmd.splice(2, 0, "-q");
|
|
opts.pty = true;
|
|
}
|
|
|
|
const spawn = cockpit.spawn(cmd, opts);
|
|
if (passphrase !== false)
|
|
spawn.input(passphrase + "\n");
|
|
|
|
return spawn;
|
|
}
|
|
|
|
function passphrase_test(block, passphrase) {
|
|
const dev = decode_filename(block.Device);
|
|
return (cockpit.spawn(["cryptsetup", "luksOpen", "--test-passphrase", dev],
|
|
{ superuser: true, err: "message" }).input(passphrase)
|
|
.then(() => true)
|
|
.catch(() => false));
|
|
}
|
|
|
|
/* Dialogs
|
|
*/
|
|
|
|
export function existing_passphrase_fields(explanation) {
|
|
return [
|
|
Skip("medskip", { visible: vals => vals.needs_explicit_passphrase }),
|
|
PassInput("passphrase", _("Disk passphrase"),
|
|
{
|
|
visible: vals => vals.needs_explicit_passphrase,
|
|
validate: val => !val.length && _("Passphrase cannot be empty"),
|
|
explanation: explanation
|
|
})
|
|
];
|
|
}
|
|
|
|
function get_stored_passphrase(block, just_type) {
|
|
const pub_config = array_find(block.Configuration, function (c) { return c[0] == "crypttab" });
|
|
if (pub_config && pub_config[1]["passphrase-path"] && decode_filename(pub_config[1]["passphrase-path"].v) != "") {
|
|
if (just_type)
|
|
return Promise.resolve("stored");
|
|
return block.GetSecretConfiguration({}).then(function (items) {
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i][0] == 'crypttab' && items[i][1]['passphrase-contents'])
|
|
return decode_filename(items[i][1]['passphrase-contents'].v);
|
|
}
|
|
return "";
|
|
});
|
|
}
|
|
}
|
|
|
|
export function get_existing_passphrase(block, just_type) {
|
|
return clevis_recover_passphrase(block, just_type).then(passphrase => {
|
|
return passphrase || get_stored_passphrase(block, just_type);
|
|
});
|
|
}
|
|
|
|
export function request_passphrase_on_error_handler(dlg, vals, recovered_passphrase, block) {
|
|
return function (error) {
|
|
if (vals.passphrase === undefined) {
|
|
return (passphrase_test(block, recovered_passphrase)
|
|
.then(good => {
|
|
if (!good)
|
|
dlg.set_values({ needs_explicit_passphrase: true });
|
|
return Promise.reject(error);
|
|
}));
|
|
} else
|
|
return Promise.reject(error);
|
|
};
|
|
}
|
|
|
|
export function init_existing_passphrase(block, just_type, callback) {
|
|
return {
|
|
title: _("Unlocking disk"),
|
|
func: dlg => {
|
|
return get_existing_passphrase(block, just_type).then(passphrase => {
|
|
if (!passphrase)
|
|
dlg.set_values({ needs_explicit_passphrase: true });
|
|
if (callback)
|
|
callback(passphrase);
|
|
return passphrase;
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function parse_url(url) {
|
|
// clevis-encrypt-tang defaults to "http://" (via curl), so we do the same here.
|
|
if (!/^[a-zA-Z]+:\/\//.test(url))
|
|
url = "http://" + url;
|
|
try {
|
|
return new URL(url);
|
|
} catch (e) {
|
|
if (e instanceof TypeError)
|
|
return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function validate_url(url) {
|
|
if (url.length === 0)
|
|
return _("Address cannot be empty");
|
|
if (!parse_url(url))
|
|
return _("Address is not a valid URL");
|
|
}
|
|
|
|
function add_dialog(client, block) {
|
|
let recovered_passphrase;
|
|
|
|
dialog_open({
|
|
Title: _("Add key"),
|
|
Fields: [
|
|
SelectOneRadio("type", _("Key source"),
|
|
{
|
|
value: "luks-passphrase",
|
|
visible: vals => client.features.clevis,
|
|
widest_title: _("Repeat passphrase"),
|
|
choices: [
|
|
{ value: "luks-passphrase", title: _("Passphrase") },
|
|
{ value: "tang", title: _("Tang keyserver") }
|
|
]
|
|
}),
|
|
Skip("medskip"),
|
|
PassInput("new_passphrase", _("New passphrase"),
|
|
{
|
|
visible: vals => !client.features.clevis || vals.type == "luks-passphrase",
|
|
validate: val => !val.length && _("Passphrase cannot be empty"),
|
|
}),
|
|
PassInput("new_passphrase2", _("Repeat passphrase"),
|
|
{
|
|
visible: vals => !client.features.clevis || vals.type == "luks-passphrase",
|
|
validate: (val, vals) => {
|
|
return (vals.new_passphrase.length &&
|
|
vals.new_passphrase != val &&
|
|
_("Passphrases do not match"));
|
|
}
|
|
}),
|
|
TextInput("tang_url", _("Keyserver address"),
|
|
{
|
|
visible: vals => client.features.clevis && vals.type == "tang",
|
|
validate: validate_url
|
|
})
|
|
].concat(existing_passphrase_fields(_("Saving a new passphrase requires unlocking the disk. Please provide a current disk passphrase."))),
|
|
Action: {
|
|
Title: _("Add"),
|
|
action: function (vals) {
|
|
const existing_passphrase = vals.passphrase || recovered_passphrase;
|
|
if (!client.features.clevis || vals.type == "luks-passphrase") {
|
|
return passphrase_add(block, vals.new_passphrase, existing_passphrase);
|
|
} else {
|
|
return get_tang_adv(vals.tang_url).then(function (adv) {
|
|
edit_tang_adv(client, block, null,
|
|
vals.tang_url, adv, existing_passphrase);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
Inits: [
|
|
init_existing_passphrase(block, false, pp => { recovered_passphrase = pp })
|
|
]
|
|
});
|
|
}
|
|
|
|
function edit_passphrase_dialog(block, key) {
|
|
dialog_open({
|
|
Title: _("Change passphrase"),
|
|
Fields: [
|
|
PassInput("old_passphrase", _("Old passphrase"),
|
|
{ validate: val => !val.length && _("Passphrase cannot be empty") }),
|
|
Skip("medskip"),
|
|
PassInput("new_passphrase", _("New passphrase"),
|
|
{ validate: val => !val.length && _("Passphrase cannot be empty") }),
|
|
PassInput("new_passphrase2", _("Repeat passphrase"),
|
|
{ validate: (val, vals) => vals.new_passphrase.length && vals.new_passphrase != val && _("Passphrases do not match") })
|
|
],
|
|
Action: {
|
|
Title: _("Save"),
|
|
action: vals => passphrase_change(block, key, vals.new_passphrase, vals.old_passphrase)
|
|
}
|
|
});
|
|
}
|
|
|
|
function edit_clevis_dialog(client, block, key) {
|
|
let recovered_passphrase;
|
|
|
|
dialog_open({
|
|
Title: _("Edit Tang keyserver"),
|
|
Fields: [
|
|
TextInput("tang_url", _("Keyserver address"),
|
|
{
|
|
validate: validate_url,
|
|
value: key.url
|
|
})
|
|
].concat(existing_passphrase_fields(_("Saving a new passphrase requires unlocking the disk. Please provide a current disk passphrase."))),
|
|
Action: {
|
|
Title: _("Save"),
|
|
action: function (vals) {
|
|
const existing_passphrase = vals.passphrase || recovered_passphrase;
|
|
return get_tang_adv(vals.tang_url).then(adv => {
|
|
edit_tang_adv(client, block, key, vals.tang_url, adv, existing_passphrase);
|
|
});
|
|
}
|
|
},
|
|
Inits: [
|
|
init_existing_passphrase(block, false, pp => { recovered_passphrase = pp })
|
|
]
|
|
});
|
|
}
|
|
|
|
function edit_tang_adv(client, block, key, url, adv, passphrase) {
|
|
const parsed = parse_url(url);
|
|
const cmd = cockpit.format("ssh $0 tang-show-keys $1", parsed.hostname, parsed.port);
|
|
|
|
const sigkey_thps = compute_sigkey_thps(tang_adv_payload(adv));
|
|
|
|
const dlg = dialog_open({
|
|
Title: _("Verify key"),
|
|
Body: (
|
|
<>
|
|
<p>{_("Make sure the key hash from the Tang server matches one of the following:")}</p>
|
|
|
|
<h2 className="sigkey-heading">{_("SHA256")}</h2>
|
|
{ sigkey_thps.map(s => <p key={s} className="sigkey-hash">{s.sha256}</p>) }
|
|
|
|
<h2 className="sigkey-heading">{_("SHA1")}</h2>
|
|
{ sigkey_thps.map(s => <p key={s} className="sigkey-hash">{s.sha1}</p>) }
|
|
|
|
<p>
|
|
{_("Manually check with SSH: ")}
|
|
<ClipboardCopy hoverTip={_("Copy to clipboard")}
|
|
clickTip={_("Successfully copied to clipboard!")}
|
|
variant="inline-compact"
|
|
isCode>
|
|
{cmd}
|
|
</ClipboardCopy>
|
|
</p>
|
|
</>
|
|
),
|
|
Fields: existing_passphrase_fields(_("Saving a new passphrase requires unlocking the disk. Please provide a current disk passphrase.")),
|
|
Action: {
|
|
Title: _("Trust key"),
|
|
action: function (vals) {
|
|
return clevis_add(block, "tang", { url: url, adv: adv }, vals.passphrase || passphrase).then(() => {
|
|
if (key)
|
|
return clevis_remove(block, key);
|
|
})
|
|
.catch(request_passphrase_on_error_handler(dlg, vals, passphrase, block));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const RemovePassphraseField = (tag, key, dev) => {
|
|
function validate(val) {
|
|
if (val === "")
|
|
return _("Passphrase can not be empty");
|
|
}
|
|
|
|
return {
|
|
tag: tag,
|
|
title: null,
|
|
options: { validate: validate },
|
|
initial_value: "",
|
|
bare: true,
|
|
|
|
render: (val, change, validated, error) => {
|
|
return (
|
|
<Stack hasGutter>
|
|
<p>{ fmt_to_fragments(_("Passphrase removal may prevent unlocking $0."), <b>{dev}</b>) }</p>
|
|
<Form>
|
|
<Checkbox id="force-remove-passphrase"
|
|
isChecked={val !== false}
|
|
label={_("Confirm removal with an alternate passphrase")}
|
|
onChange={checked => change(checked ? "" : false)}
|
|
body={val === false
|
|
? <p className="slot-warning">
|
|
{_("Removing a passphrase without confirmation of another passphrase may prevent unlocking or key management, if other passphrases are forgotten or lost.")}
|
|
</p>
|
|
: <FormGroup label={_("Passphrase from any other key slot")} fieldId="remove-passphrase">
|
|
<TextInputPF id="remove-passphrase" type="password" value={val} onChange={value => change(value)} />
|
|
</FormGroup>
|
|
}
|
|
/>
|
|
</Form>
|
|
</Stack>
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
function remove_passphrase_dialog(block, key) {
|
|
dialog_open({
|
|
Title: cockpit.format(_("Remove passphrase in key slot $0?"), key.slot),
|
|
Fields: [
|
|
RemovePassphraseField("passphrase", key, block_name(block))
|
|
],
|
|
isFormHorizontal: false,
|
|
Action: {
|
|
DangerButton: true,
|
|
Title: _("Remove"),
|
|
action: function (vals) {
|
|
return slot_remove(block, key.slot, vals.passphrase);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const RemoveClevisField = (tag, key, dev) => {
|
|
return {
|
|
tag: tag,
|
|
title: null,
|
|
options: { },
|
|
initial_value: "",
|
|
bare: true,
|
|
|
|
render: (val, change) => {
|
|
return (
|
|
<div data-field={tag}>
|
|
<p>{ fmt_to_fragments(_("Remove $0?"), <b>{key.url}</b>) }</p>
|
|
<p className="slot-warning">{ fmt_to_fragments(_("Keyserver removal may prevent unlocking $0."), <b>{dev}</b>) }</p>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
};
|
|
|
|
function remove_clevis_dialog(client, block, key) {
|
|
dialog_open({
|
|
Title: _("Remove Tang keyserver?"),
|
|
Fields: [
|
|
RemoveClevisField("keyserver", key, block_name(block))
|
|
],
|
|
Action: {
|
|
DangerButton: true,
|
|
Title: _("Remove"),
|
|
action: function () {
|
|
return clevis_remove(block, key);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export class CryptoKeyslots extends React.Component {
|
|
render() {
|
|
const { client, block, slots, slot_error, max_slots } = this.props;
|
|
|
|
if ((slots == null && slot_error == null) || slot_error == "not-found")
|
|
return null;
|
|
|
|
function decode_clevis_slot(slot) {
|
|
if (slot.ClevisConfig) {
|
|
const clevis = JSON.parse(slot.ClevisConfig.v);
|
|
if (clevis.pin && clevis.pin == "tang" && clevis.tang) {
|
|
return {
|
|
slot: slot.Index.v,
|
|
type: "tang",
|
|
url: clevis.tang.url
|
|
};
|
|
} else {
|
|
return {
|
|
slot: slot.Index.v,
|
|
type: "unknown",
|
|
pin: clevis.pin
|
|
};
|
|
}
|
|
} else {
|
|
return {
|
|
slot: slot.Index.v,
|
|
type: "luks-passphrase"
|
|
};
|
|
}
|
|
}
|
|
|
|
const keys = slots ? slots.map(decode_clevis_slot).filter(k => !!k) : [];
|
|
|
|
let rows;
|
|
if (keys.length == 0) {
|
|
let text;
|
|
if (slot_error) {
|
|
if (slot_error.problem == "access-denied")
|
|
text = _("The currently logged in user is not permitted to see information about keys.");
|
|
else
|
|
text = slot_error.toString();
|
|
} else {
|
|
text = _("No keys added");
|
|
}
|
|
rows = <tr><td className="text-center">{text}</td></tr>;
|
|
} else {
|
|
rows = [];
|
|
|
|
const add_row = (slot, type, desc, edit, edit_excuse, remove) => {
|
|
rows.push(
|
|
<DataListItem key={slot}>
|
|
<DataListItemRow>
|
|
<DataListItemCells
|
|
dataListCells={[
|
|
<DataListCell key="key-type">
|
|
{ type }
|
|
</DataListCell>,
|
|
<DataListCell key="desc" isFilled={false}>
|
|
{ desc }
|
|
</DataListCell>,
|
|
<DataListCell key="key-slot">
|
|
{ cockpit.format(_("Slot $0"), slot) }
|
|
</DataListCell>,
|
|
<DataListCell key="text-right" isFilled={false} alignRight>
|
|
<StorageButton onClick={edit}
|
|
ariaLabel={_("Edit")}
|
|
excuse={(keys.length == max_slots)
|
|
? _("Editing a key requires a free slot")
|
|
: null}>
|
|
<EditIcon />
|
|
</StorageButton>
|
|
{ "\n" }
|
|
<StorageButton onClick={remove}
|
|
ariaLabel={_("Remove")}
|
|
excuse={keys.length == 1 ? _("The last key slot can not be removed") : null}>
|
|
<MinusIcon />
|
|
</StorageButton>
|
|
</DataListCell>,
|
|
]}
|
|
/>
|
|
</DataListItemRow>
|
|
</DataListItem>
|
|
);
|
|
};
|
|
|
|
keys.sort((a, b) => a.slot - b.slot).forEach(key => {
|
|
if (key.type == "luks-passphrase") {
|
|
add_row(key.slot,
|
|
_("Passphrase"), "",
|
|
() => edit_passphrase_dialog(block, key), null,
|
|
() => remove_passphrase_dialog(block, key));
|
|
} else if (key.type == "tang") {
|
|
add_row(key.slot,
|
|
_("Keyserver"), key.url,
|
|
() => edit_clevis_dialog(client, block, key), null,
|
|
() => remove_clevis_dialog(client, block, key));
|
|
} else {
|
|
add_row(key.slot,
|
|
_("Unknown type"), "",
|
|
null, _("Key slots with unknown types can not be edited here"),
|
|
() => remove_clevis_dialog(client, block, key));
|
|
}
|
|
});
|
|
}
|
|
|
|
const remaining = max_slots - keys.length;
|
|
|
|
return (
|
|
<Card className="key-slot-panel">
|
|
<CardHeader>
|
|
<CardActions>
|
|
<span className="key-slot-panel-remaining">
|
|
{ remaining < 6 ? (remaining ? cockpit.format(cockpit.ngettext("$0 slot remains", "$0 slots remain", remaining), remaining) : _("No available slots")) : null }
|
|
</span>
|
|
<StorageButton onClick={() => add_dialog(client, block)}
|
|
ariaLabel={_("Add")}
|
|
excuse={(keys.length == max_slots)
|
|
? _("No free key slots")
|
|
: null}>
|
|
<PlusIcon />
|
|
</StorageButton>
|
|
</CardActions>
|
|
<CardTitle><Text component={TextVariants.h2}>{_("Keys")}</Text></CardTitle>
|
|
</CardHeader>
|
|
<CardBody className="contains-list">
|
|
<DataList isCompact className="crypto-keyslots-list" aria-label={_("Keys")}>
|
|
{rows}
|
|
</DataList>
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
}
|
|
}
|