LCOV - code coverage report
Current view: top level - pkg/systemd/overview-cards - realmd.jsx Coverage Total Hit
Test: cockpit Lines: 92.6 % 378 350
Test Date: 2026-07-17 14:32:59

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2021 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           94 : import React, { useState, useEffect } from 'react';
       7              : 
       8              : import { Alert, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
       9              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      10              : import { CodeBlockCode } from "@patternfly/react-core/dist/esm/components/CodeBlock/index.js";
      11              : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
      12              : import { ExpandableSection } from "@patternfly/react-core/dist/esm/components/ExpandableSection/index.js";
      13              : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
      14              : import {
      15              :     Modal, ModalBody, ModalFooter, ModalHeader
      16              : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
      17              : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
      18              : import { CheckIcon, ExclamationCircleIcon, InProgressIcon } from "@patternfly/react-icons";
      19              : 
      20           94 : import cockpit from "cockpit";
      21              : import { Privileged } from "cockpit-components-privileged";
      22              : import { superuser } from "superuser";
      23              : import { useEvent } from "hooks.js";
      24              : import { FormHelper } from "cockpit-components-form-helper";
      25              : import { install_dialog } from "cockpit-components-install-dialog.jsx";
      26              : import { useDialogs } from "dialogs.jsx";
      27              : import { getPackageManager } from 'packagemanager';
      28              : 
      29              : import "./realmd.scss";
      30              : 
      31           94 : const _ = cockpit.gettext;
      32              : 
      33           94 : const MANAGER = "/org/freedesktop/realmd";
      34           94 : const PROVIDER = "org.freedesktop.realmd.Provider";
      35           94 : const KERBEROS = "org.freedesktop.realmd.Kerberos";
      36           94 : const KERBEROS_MEMBERSHIP = "org.freedesktop.realmd.KerberosMembership";
      37           94 : const REALM = "org.freedesktop.realmd.Realm";
      38              : 
      39           94 : export class RealmdClient {
      40           93 :     constructor() {
      41           93 :         this.onClose = this.onClose.bind(this);
      42           93 :         this.onRealmsChanged = this.onRealmsChanged.bind(this);
      43           93 :         this.joined = [];
      44           93 :         this.detected = null;
      45           93 :         this.error = null;
      46           93 :         this.install_realmd = false;
      47           93 :         this.callSerial = 1;
      48              : 
      49           93 :         cockpit.event_target(this);
      50           93 :         this.initProxy();
      51           89 :         superuser.addEventListener("changed", () => this.initProxy());
      52           93 :     }
      53              : 
      54           15 :     onClose(ev, options) {
      55            5 :         if (options.problem === "not-found") {
      56              :             // see if we can install it
      57            2 :             getPackageManager().then(() => {
      58            2 :                 this.error = _("Joining a domain requires installation of realmd");
      59            2 :                 this.install_realmd = true;
      60            2 :                 this.dispatchEvent("changed");
      61            0 :             }).catch(exc => {
      62            0 :                 this.error = _("Cannot join a domain because realmd is not available on this system");
      63            0 :                 this.dispatchEvent("changed");
      64            0 :             });
      65            3 :         } else {
      66           13 :             this.error = cockpit.message(options);
      67           13 :             this.dispatchEvent("changed");
      68           13 :         }
      69           15 :         this.dbus_realmd.removeEventListener("close", this.onClose);
      70           15 :         this.dbus_realmd.close();
      71           15 :         this.dbus_realmd = null;
      72           15 :     }
      73              : 
      74            3 :     onRealmsChanged() {
      75            3 :         this.joined = [];
      76            3 :         for (const path in this.realms) {
      77            3 :             const realm = this.realms[path];
      78            3 :             if (realm.Configured)
      79            2 :                 this.joined.push(realm);
      80            3 :         }
      81              : 
      82            3 :         this.dispatchEvent("changed");
      83            3 :     }
      84              : 
      85           93 :     initProxy() {
      86              :         // Ignore intermediate states of superuser.allowed to
      87              :         // avoid initializing the proxy twice during page
      88              :         // load. This is less wasteful and helps the tests avoid
      89              :         // race conditions. We are guaranteed to see a real "true"
      90              :         // or "false" value eventually.
      91              :         //
      92           93 :         if (superuser.allowed === null)
      93           93 :             return;
      94              : 
      95           25 :         if (this.dbus_realmd) {
      96           25 :             this.dbus_realmd.removeEventListener("close", this.onClose);
      97           25 :             this.dbus_realmd.close();
      98           25 :         }
      99              : 
     100           89 :         this.error = null;
     101           89 :         this.dbus_realmd = cockpit.dbus("org.freedesktop.realmd", { superuser: "try" });
     102           89 :         this.dbus_realmd.watch(MANAGER);
     103           89 :         this.dbus_realmd.addEventListener("close", this.onClose);
     104              : 
     105           89 :         this.realms = this.dbus_realmd.proxies(REALM, MANAGER);
     106           89 :         this.realms.addEventListener("changed", this.onRealmsChanged);
     107           93 :     }
     108              : 
     109            5 :     checkRealm(name) {
     110            5 :         return this.dbus_realmd.call(MANAGER, PROVIDER, "Discover", [name, {}])
     111            4 :                 .then(([relevance, realms]) => {
     112            4 :                     if (realms.length == 0)
     113            3 :                         return { result: false };
     114              : 
     115              :                     // the first realm
     116            3 :                     const path = realms[0];
     117            3 :                     const realm = this.dbus_realmd.proxy(REALM, path);
     118            3 :                     const kerberos_membership = this.dbus_realmd.proxy(KERBEROS_MEMBERSHIP, path);
     119            3 :                     return Promise.allSettled([realm.wait(), kerberos_membership.wait()])
     120            3 :                             .then(() => { return { result: true, realm, kerberos_membership } });
     121            4 :                 });
     122            5 :     }
     123              : 
     124            2 :     join(realm, kerberosMembership, user, password) {
     125            2 :         const id = "cockpit-" + this.callSerial;
     126            2 :         this.callSerial += 1;
     127            2 :         let diagnostics = "";
     128              : 
     129            2 :         const options = { operation: cockpit.variant('s', id) };
     130            2 :         const diagnostics_sub = this.dbus_realmd.subscribe({ member: "Diagnostics" }, (path, iface, signal, args) => {
     131            2 :             if (args[1] === id) {
     132            2 :                 diagnostics += args[0];
     133            2 :             }
     134            2 :         });
     135              : 
     136            2 :         if (kerberosMembership.valid) {
     137            2 :             const credentials = ["password", "administrator", cockpit.variant('(ss)', [user, password])];
     138            2 :             return kerberosMembership.call("Join", [credentials, options])
     139            2 :                     .then(() => this.installWSCredentials(realm, user, password))
     140            2 :                     .catch(ex => {
     141            2 :                         if (ex.name == "org.freedesktop.realmd.Error.Cancelled")
     142            0 :                             return Promise.resolve();
     143            2 :                         ex.diagnostics = diagnostics;
     144            2 :                         return Promise.reject(ex);
     145            2 :                     })
     146            2 :                     .finally(() => diagnostics_sub.remove());
     147            0 :         } else {
     148            0 :             return Promise.reject(new Error(_("Joining this domain is not supported")));
     149            0 :         }
     150            2 :     }
     151              : 
     152            2 :     leave(realm) {
     153            2 :         return this.cleanupWSCredentials(realm)
     154            2 :                 .then(() => realm.Deconfigure({ operation: cockpit.variant('s', "cockpit-leave-domain") }));
     155            2 :     }
     156              : 
     157            2 :     installWSCredentials(realm, user, password) {
     158              :         // skip this on remote ssh hosts, only set up ws hosts
     159            2 :         if (cockpit.transport.host !== "localhost")
     160            0 :             return true;
     161              : 
     162            2 :         const server_sw = find_detail(realm, "server-software");
     163            1 :         if (server_sw !== "ipa") {
     164            1 :             console.log("installing ws credentials not supported for server software", server_sw);
     165            1 :             return true;
     166            1 :         }
     167              : 
     168            1 :         const kerberos = this.dbus_realmd.proxy(KERBEROS, realm.path);
     169            1 :         return kerberos.wait()
     170            1 :                 .then(() => {
     171            1 :                     const helper = cockpit.manifests.system.libexecdir + "/cockpit-certificate-helper";
     172            1 :                     const proc = cockpit.spawn([helper, "ipa", "request", kerberos.RealmName, user],
     173            1 :                                                { superuser: "require", err: "message" });
     174            1 :                     proc.input(password);
     175            0 :                     proc.catch(ex => console.warn("Failed to run", helper, "ipa request:", ex.toString()));
     176            1 :                     return proc;
     177            1 :                 })
     178            0 :                 .catch(() => true); // no Kerberos domain? nevermind then
     179            2 :     }
     180              : 
     181            2 :     cleanupWSCredentials(realm) {
     182              :         // skip this on remote ssh hosts, only set up ws hosts
     183            2 :         if (cockpit.transport.host !== "localhost")
     184            0 :             return Promise.resolve();
     185              : 
     186            2 :         const server_sw = find_detail(realm, "server-software");
     187            1 :         if (server_sw !== "ipa") {
     188            1 :             console.log("cleaning up ws credentials not supported for server software", server_sw);
     189            1 :             return Promise.resolve();
     190            1 :         }
     191              : 
     192            1 :         const kerberos = this.dbus_realmd.proxy(KERBEROS, realm.path);
     193            1 :         return kerberos.wait()
     194            1 :                 .then(() => {
     195            1 :                     const helper = cockpit.manifests.system.libexecdir + "/cockpit-certificate-helper";
     196            1 :                     return cockpit.spawn([helper, "ipa", "cleanup", kerberos.RealmName],
     197            1 :                                          { superuser: "require", err: "message" })
     198            0 :                             .catch(ex => {
     199            0 :                                 console.log("Failed to clean up SPN from /etc/cockpit/krb5.keytab:", JSON.stringify(ex));
     200            0 :                                 return true;
     201            0 :                             });
     202            1 :                 })
     203            0 :                 .catch(() => true); // no Kerberos domain? nevermind then
     204            2 :     }
     205              : 
     206           59 :     allowHostnameChange() {
     207           59 :         return this.joined.length === 0;
     208           59 :     }
     209              : 
     210            5 :     installPackage() {
     211            2 :         if (this.install_realmd) {
     212            2 :             return install_dialog("realmd")
     213            2 :                     .then(() => {
     214            2 :                         this.install_realmd = false;
     215            2 :                         this.initProxy();
     216            2 :                         return true;
     217            2 :                     })
     218            0 :                     .catch(() => false); // dialog cancelled
     219            2 :         }
     220              : 
     221            5 :         return null;
     222            5 :     }
     223           94 : }
     224              : 
     225              : /*
     226              :  * The realmd dbus interface has an a(ss) Details
     227              :  * property. Lookup the right value for the given
     228              :  * field key.
     229              :  */
     230            2 : function find_detail(realm, field) {
     231            2 :     let result = null;
     232            2 :     if (realm && realm.Details) {
     233            2 :         realm.Details.forEach(value => {
     234            2 :             if (value[0] === field)
     235            2 :                 result = value[1];
     236            2 :         });
     237            2 :     }
     238            2 :     return result;
     239            2 : }
     240              : 
     241            2 : const LeaveDialog = ({ realmd_client }) => {
     242            2 :     const Dialogs = useDialogs();
     243            2 :     const [expanded, setExpanded] = useState(false);
     244            2 :     const [pending, setPending] = useState(false);
     245            2 :     const [error, setError] = useState(null);
     246            2 :     const realm = realmd_client.joined[0];
     247              : 
     248            2 :     const onLeave = () => {
     249            2 :         setPending(true);
     250            2 :         realmd_client.leave(realm)
     251            2 :                 .then(Dialogs.close)
     252            0 :                 .catch(err => {
     253            0 :                     console.warn("Failed to leave domain:", err.toString());
     254            0 :                     setPending(false);
     255            0 :                     setError(err);
     256            0 :                 });
     257            2 :     };
     258              : 
     259            2 :     return (
     260            2 :         <Modal id="realms-leave-dialog" isOpen position="top" variant="medium"
     261            2 :             onClose={Dialogs.close}
     262              :         >
     263            2 :             <ModalHeader title={ _("dialog-title", "Domain") } />
     264            2 :             <ModalBody>
     265            2 :                 <DescriptionList isHorizontal>
     266            2 :                     <DescriptionListGroup>
     267            2 :                         <DescriptionListTerm>{ _("Domain") }</DescriptionListTerm>
     268            2 :                         <DescriptionListDescription id="realms-op-info-domain">
     269            2 :                             { realm && realm.Name }
     270            2 :                         </DescriptionListDescription>
     271            2 :                     </DescriptionListGroup>
     272            2 :                     <DescriptionListGroup>
     273            2 :                         <DescriptionListTerm>{ _("Login format") }</DescriptionListTerm>
     274            2 :                         <DescriptionListDescription id="realms-op-info-login-format">{
     275            2 :                             realm && realm.LoginFormats && realm.LoginFormats.length > 0
     276            2 :                                 ? realm.LoginFormats[0].replace("%U", "username")
     277            0 :                                 : null
     278            2 :                         }</DescriptionListDescription>
     279            2 :                     </DescriptionListGroup>
     280            2 :                     <DescriptionListGroup>
     281            2 :                         <DescriptionListTerm>{ _("Server software") }</DescriptionListTerm>
     282            2 :                         <DescriptionListDescription id="realms-op-info-server-sw">
     283            2 :                             { find_detail(realm, "server-software") }
     284            2 :                         </DescriptionListDescription>
     285            2 :                     </DescriptionListGroup>
     286            2 :                     <DescriptionListGroup>
     287            2 :                         <DescriptionListTerm>{ _("Client software") }</DescriptionListTerm>
     288            2 :                         <DescriptionListDescription id="realms-op-info-client-sw">
     289            2 :                             { find_detail(realm, "client-software") }
     290            2 :                         </DescriptionListDescription>
     291            2 :                     </DescriptionListGroup>
     292            2 :                 </DescriptionList>
     293              : 
     294            2 :                 <ExpandableSection toggleText={ _("Leave domain") } isExpanded={expanded}
     295            2 :                                     onToggle={ e => setExpanded(e) }>
     296            2 :                     <Alert variant="warning" isInline
     297            0 :                             title={ realm && realm.Name ? cockpit.format(_("Leave $0"), realm.Name) : _("Leave domain") }
     298            2 :                             actionLinks={
     299            2 :                                 <Button variant="danger" id="realms-op-leave" isDisabled={pending} onClick={onLeave}>{ _("Leave domain") }</Button>
     300              :                             }>
     301            2 :                         { _("After leaving the domain, only users with local credentials will be able to log into this machine. This may also affect other services as DNS resolution settings and the list of trusted CAs may change.") }
     302            2 :                     </Alert>
     303            2 :                 </ExpandableSection>
     304            2 :             </ModalBody>
     305            2 :             <ModalFooter>
     306            0 :                 { error && <Alert variant="danger" isInline className="realms-op-error" title={error.toString()} /> }
     307            2 :                 <Button variant="secondary" isDisabled={pending} onClick={Dialogs.close}>{ _("Close") }</Button>
     308            2 :             </ModalFooter>
     309            2 :         </Modal>);
     310            2 : };
     311              : 
     312           94 : let domainValidateTimeout;
     313              : 
     314            5 : const JoinDialog = ({ realmd_client }) => {
     315            5 :     const Dialogs = useDialogs();
     316            5 :     const [pending, setPending] = useState(false);
     317            5 :     const [address, setAddress] = useState("");
     318            5 :     const [addressValid, setAddressValid] = useState(null); // success, error, unsupported, default (for pending check)
     319            5 :     const [admin, setAdmin] = useState("");
     320            5 :     const [adminPassword, setAdminPassword] = useState("");
     321            5 :     const [realm, setRealm] = useState(null);
     322            5 :     const [kerberosMembership, setKerberosMembership] = useState(null);
     323            5 :     const [error, setError] = useState(null);
     324            5 :     const [diagnosticsExpanded, setDiagnosticsExpanded] = useState(false);
     325              : 
     326            5 :     const checkAddress = name => {
     327            5 :         setAddressValid("default");
     328              : 
     329            5 :         realmd_client.checkRealm(name)
     330            4 :                 .then(reply => {
     331            3 :                     if (reply.result) {
     332            3 :                         setRealm(reply.realm);
     333              : 
     334              :                         // handle initial auto-detection
     335            3 :                         if (name == "") {
     336            3 :                             if (!address)
     337            3 :                                 setAddress(reply.realm.Name);
     338            3 :                         }
     339              : 
     340            2 :                         if (reply.kerberos_membership && reply.kerberos_membership.valid) {
     341            2 :                             setAddressValid("success");
     342            2 :                             if (!admin && reply.kerberos_membership.SuggestedAdministrator)
     343            2 :                                 setAdmin(reply.kerberos_membership.SuggestedAdministrator);
     344            2 :                             setKerberosMembership(reply.kerberos_membership);
     345            0 :                         } else {
     346            1 :                             setAddressValid("unsupported");
     347            1 :                         }
     348            2 :                     } else {
     349              :                         // error_detect will not show the validation error, but trigger data-discover=done
     350            0 :                         setAddressValid(name ? "error" : "error_detect");
     351            3 :                     }
     352            4 :                 })
     353            1 :                 .catch(err => console.error("checkRealm failed", JSON.stringify(err)));
     354            5 :     };
     355              : 
     356            3 :     const validateAddress = value => {
     357            3 :         setAddress(value);
     358            3 :         setAddressValid(null);
     359            3 :         window.clearTimeout(domainValidateTimeout);
     360            3 :         if (value)
     361            3 :             domainValidateTimeout = window.setTimeout(() => checkAddress(value), 1000);
     362            3 :     };
     363              : 
     364            2 :     const onJoin = () => {
     365            2 :         setError(null);
     366            2 :         setDiagnosticsExpanded(null);
     367            2 :         setPending(true);
     368            2 :         realmd_client.join(realm, kerberosMembership, admin, adminPassword)
     369            2 :                 .then(Dialogs.close)
     370            2 :                 .catch(err => {
     371            2 :                     setPending(false);
     372            2 :                     setError(err);
     373            2 :                 });
     374            2 :     };
     375              : 
     376              :     // initial auto-detection of domain name
     377            5 :     useEffect(() => checkAddress(""), []); // eslint-disable-line react-hooks/exhaustive-deps
     378              : 
     379            2 :     const join_disabled = pending || addressValid !== "success" || !admin || !kerberosMembership;
     380              : 
     381            5 :     const DOMAIN_VALID_HELPER_TEXT = {
     382            5 :         default: _("Validating address"),
     383            5 :         success: _("Contacted domain"),
     384            5 :         error: _("Domain could not be contacted"),
     385            5 :         unsupported: _("Domain is not supported"),
     386            5 :     };
     387              : 
     388            5 :     const DOMAIN_VALID_HELPER_ICON = {
     389            5 :         default: <InProgressIcon />,
     390            5 :         success: <CheckIcon />,
     391            5 :         error: <ExclamationCircleIcon />,
     392            5 :         unsupported: <ExclamationCircleIcon />,
     393            5 :     };
     394              : 
     395            5 :     const domainHelperText = DOMAIN_VALID_HELPER_TEXT[addressValid];
     396            5 :     const domainHelperIcon = DOMAIN_VALID_HELPER_ICON[addressValid];
     397              : 
     398            5 :     let errorAlert;
     399            2 :     if (error) {
     400            2 :         const details = (error.diagnostics && diagnosticsExpanded)
     401            2 :             ? <CodeBlockCode className="realms-op-diagnostics">{error.diagnostics}</CodeBlockCode>
     402            2 :             : null;
     403            2 :         const actionLink = (error.diagnostics && !diagnosticsExpanded)
     404            2 :             ? <AlertActionLink onClick={ () => setDiagnosticsExpanded(true) }>{ _("Details") }</AlertActionLink>
     405            2 :             : null;
     406            2 :         errorAlert = <Alert variant="danger" isInline className="realms-op-error" actionLinks={actionLink} title={error.toString()}>{details}</Alert>;
     407            2 :     }
     408              : 
     409            5 :     return (
     410            5 :         <Modal id="realms-join-dialog" isOpen position="top" variant="medium"
     411            5 :             onClose={Dialogs.close}
     412              :         >
     413            5 :             <ModalHeader title={ _("dialog-title", "Join a domain") } />
     414            5 :             <ModalBody>
     415            5 :                 <Form isHorizontal onSubmit={onJoin}>
     416            5 :                     <FormGroup label={ _("Domain address") } fieldId="realms-op-address" validated={addressValid}>
     417            5 :                         <TextInput id="realms-op-address" placeholder="domain.example.com"
     418            4 :                                    data-discover={ (!addressValid || addressValid == "default") ? null : "done" }
     419            5 :                                    autoComplete="url"
     420            3 :                                    value={address} onChange={(_event, value) => validateAddress(value)} isDisabled={pending} />
     421            5 :                     </FormGroup>
     422              : 
     423            5 :                     <FormGroup label={ _("Domain administrator name") } fieldId="realms-op-admin">
     424            1 :                         <TextInput id="realms-op-admin" placeholder="admin" value={admin} autoComplete="username" onChange={(_event, value) => setAdmin(value)} isDisabled={pending} />
     425            5 :                     </FormGroup>
     426            5 :                     <FormGroup label={ _("Domain administrator password") } fieldId="realms-op-admin-password">
     427            3 :                         <TextInput id="realms-op-admin-password" type="password" value={adminPassword} autoComplete="current-password" onChange={(_event, value) => setAdminPassword(value)} isDisabled={pending} />
     428            5 :                     </FormGroup>
     429            2 :                     <FormHelper fieldId="realms-op-address" helperText={domainHelperText} helperTextInvalid={addressValid == "error" && domainHelperText} icon={domainHelperIcon} />
     430            5 :                 </Form>
     431            5 :             </ModalBody>
     432            5 :             <ModalFooter>
     433            5 :                 { errorAlert }
     434            5 :                 <Button variant="primary"
     435            5 :                         isDisabled={join_disabled}
     436            5 :                         onClick={onJoin}
     437            5 :                         isLoading={pending}
     438            2 :                         spinnerAriaValueText={ pending ? _("Joining") : null }>
     439            5 :                     { _("Join") }
     440            5 :                 </Button>
     441            5 :                 <Button variant="link" isDisabled={pending} onClick={Dialogs.close}>{ _("Cancel") }</Button>
     442            2 :                 { pending && <span className="realms-op-wait-message">{ _("This may take a while") }</span> }
     443            5 :             </ModalFooter>
     444            5 :         </Modal>);
     445            5 : };
     446              : 
     447           93 : export const RealmButton = ({ realmd_client }) => {
     448           93 :     const Dialogs = useDialogs();
     449           93 :     useEvent(realmd_client, "changed");
     450           93 :     useEvent(superuser, "changed");
     451              : 
     452           64 :     const buttonTooltip = superuser.allowed ? realmd_client.error : _("Not permitted to configure realms");
     453            2 :     const buttonText = !realmd_client.install_realmd ? (realmd_client.joined.length ? realmd_client.joined.map(r => r.Name).join(", ") : _("Join domain")) : _("Install realmd support");
     454           19 :     const buttonDisabled = !superuser.allowed || (realmd_client.error && !realmd_client.install_realmd);
     455              : 
     456            5 :     const onClicked = () => {
     457              :         // handle on-demand realmd package install
     458            5 :         const install_promise = realmd_client.installPackage();
     459            2 :         if (install_promise) {
     460              :             // after installation, proceed to join dialog
     461            2 :             install_promise.then(success => success && onClicked());
     462            2 :             return null;
     463            2 :         }
     464            5 :         realmd_client.joined.length > 0
     465            2 :             ? Dialogs.show(<LeaveDialog realmd_client={realmd_client} />)
     466            5 :             : Dialogs.show(<JoinDialog realmd_client={realmd_client} />);
     467            5 :     };
     468              : 
     469           93 :     return (
     470           64 :         <Privileged allowed={ superuser.allowed && !realmd_client.error }
     471           93 :                     tooltipId="system_information_domain_tooltip"
     472           93 :                     excuse={ buttonTooltip }>
     473           93 :             <Button id="system_information_domain_button" variant="link"
     474           93 :                     onClick={onClicked}
     475           93 :                     isInline isDisabled={buttonDisabled} aria-label={buttonText}>
     476           93 :                 { buttonText }
     477           93 :             </Button>
     478           93 :         </Privileged>);
     479           93 : };
        

Generated by: LCOV version 2.0-1