LCOV - code coverage report
Current view: top level - pkg/lib - superuser-dialogs.tsx Coverage Total Hit
Test: cockpit Lines: 72.8 % 316 230
Test Date: 2026-06-16 14:09:37

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2020 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          162 : import cockpit from "cockpit";
       7          162 : import React, { useState } from "react";
       8              : import { useObject, useInit, useEvent } from "hooks";
       9              : import { useDialogs } from "dialogs.jsx";
      10              : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
      11              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
      12              : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
      13              : import {
      14              :     Modal, ModalBody, ModalFooter, ModalHeader
      15              : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
      16              : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
      17              : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
      18              : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      19              : import { ModalError } from 'cockpit-components-inline-notification.jsx';
      20              : import { LockIcon } from '@patternfly/react-icons';
      21              : 
      22          162 : const _ = cockpit.gettext;
      23              : 
      24          128 : export function host_superuser_storage_key(host: string | undefined) {
      25          128 :     if (!host)
      26           27 :         host = cockpit.transport.host;
      27              : 
      28          128 :     const local_key = window.localStorage.getItem("superuser-key");
      29          128 :     if (host == "localhost")
      30           19 :         return local_key;
      31           19 :     else if (host.indexOf("@") >= 0)
      32           19 :         return "superuser:" + host;
      33           19 :     else if (local_key)
      34           19 :         return local_key + "@" + host;
      35              :     else
      36           19 :         return null;
      37          128 : }
      38              : 
      39              : function sudo_polish(msg: string): string;
      40              : function sudo_polish(msg: null): null;
      41            4 : function sudo_polish(msg: string | null): string | null {
      42            4 :     if (!msg)
      43            4 :         return msg;
      44              : 
      45            4 :     msg = msg.replace(/^\[sudo(: authenticate)?] /, "");
      46            4 :     msg = msg[0].toUpperCase() + msg.slice(1);
      47              : 
      48            4 :     return msg;
      49            4 : }
      50              : 
      51              : interface Method {
      52              :     v: { label: { v: string; } };
      53              : }
      54              : 
      55              : export interface SuperuserProxy extends cockpit.DBusProxy {
      56              :     Current: string;
      57              :     Bridges: string[];
      58              :     Methods: Record<string, Method>;
      59              : 
      60              :     Start(method: string): Promise<void>;
      61              :     Stop(): Promise<void>;
      62              :     Answer(val: string): Promise<void>;
      63              : }
      64              : 
      65          157 : export function superuser_proxy(bus?: cockpit.DBusClient) {
      66          157 :     if (!bus)
      67           56 :         bus = cockpit.dbus(null, { bus: "internal" });
      68          157 :     return bus.proxy("cockpit.Superuser", "/superuser") as SuperuserProxy;
      69          157 : }
      70              : 
      71            4 : const UnlockDialog = ({
      72            4 :     proxy,
      73            4 :     host
      74            4 : } : {
      75              :     proxy: SuperuserProxy,
      76              :     host: string | undefined
      77            4 : }) => {
      78            4 :     const D = useDialogs();
      79            4 :     useInit(init, [proxy, host]);
      80              : 
      81            4 :     const [methods, setMethods] = useState<string[] | null>(null);
      82            4 :     const [method, setMethod] = useState<string | false>(false);
      83            4 :     const [busy, setBusy] = useState(false);
      84            4 :     const [cancel, setCancel] = useState(() => D.close);
      85            4 :     const [prompt, setPrompt] = useState<{ message: string, prompt: string, echo: boolean } | null>(null);
      86            4 :     const [message, setMessage] = useState<string | null>(null);
      87            4 :     const [error, setError] = useState<string | null>(null);
      88            4 :     const [errorVariant, setErrorVariant] = useState<"danger" | "warning" | null>(null);
      89            4 :     const [value, setValue] = useState("");
      90              : 
      91            4 :     function start(method: string) {
      92            4 :         setBusy(true);
      93            0 :         setCancel(() => () => {
      94            0 :             proxy.Stop();
      95            0 :             D.close();
      96            0 :         });
      97              : 
      98            4 :         let did_prompt = false;
      99              : 
     100            4 :         const onprompt = (_event: Event, message: string, prompt: string, def: string, echo: boolean, error: string) => {
     101            4 :             setBusy(false);
     102            4 :             setPrompt({
     103            4 :                 message: sudo_polish(message),
     104            4 :                 prompt: sudo_polish(prompt),
     105            4 :                 echo
     106            4 :             });
     107            4 :             setValue(def);
     108              : 
     109            1 :             if (error) {
     110            1 :                 setError(sudo_polish(error));
     111            0 :                 setErrorVariant(did_prompt ? 'danger' : 'warning');
     112            1 :             }
     113              : 
     114            4 :             did_prompt = true;
     115            4 :         };
     116              : 
     117            4 :         proxy.addEventListener("Prompt", onprompt);
     118            4 :         proxy.Start(method)
     119            4 :                 .then(() => {
     120            4 :                     proxy.removeEventListener("Prompt", onprompt);
     121              : 
     122            4 :                     const key = host_superuser_storage_key(host);
     123            4 :                     if (key)
     124            4 :                         window.localStorage.setItem(key, method);
     125            4 :                     if (did_prompt) {
     126            4 :                         D.close();
     127            0 :                     } else {
     128            0 :                         setBusy(false);
     129            0 :                         setPrompt(null);
     130            0 :                         setMessage(_("You now have administrative access."));
     131            0 :                         setCancel(() => D.close);
     132            0 :                     }
     133            4 :                 })
     134            0 :                 .catch((err: cockpit.BasicError) => {
     135            0 :                     console.warn(err);
     136            0 :                     proxy.removeEventListener("Prompt", onprompt);
     137            0 :                     if (err && err.message != "cancelled") {
     138            0 :                         setBusy(false);
     139            0 :                         setPrompt(null);
     140            0 :                         setError(sudo_polish(err.toString()));
     141            0 :                         setCancel(() => D.close);
     142            0 :                     } else
     143            0 :                         D.close();
     144            0 :                 });
     145            4 :     }
     146              : 
     147            4 :     function init() {
     148            4 :         return proxy.Stop().finally(() => {
     149            0 :             if (proxy.Bridges.length === 0) {
     150            0 :                 setError(_("No methods to gain administrative access are available (sudo -A, pkexec)."));
     151            0 :             } else if (proxy.Methods) {
     152            4 :                 const ids = Object.keys(proxy.Methods);
     153            4 :                 if (ids.length == 0)
     154            0 :                     start(proxy.Bridges[0]);
     155            0 :                 else if (ids.length == 1)
     156            0 :                     start(ids[0]);
     157            0 :                 else {
     158            0 :                     setMethods(ids);
     159            0 :                     setMethod(ids[0]);
     160            0 :                 }
     161            4 :             } else
     162            0 :                 start(proxy.Bridges[0]);
     163            4 :         });
     164            4 :     }
     165              : 
     166            0 :     const validated = errorVariant == "danger" ? "error" : errorVariant;
     167              : 
     168            4 :     let title = null;
     169            4 :     let title_icon: null | "danger" = null;
     170            4 :     let body = null;
     171            4 :     let footer = null;
     172              : 
     173            4 :     if (prompt) {
     174            0 :         if (!prompt.message && !prompt.prompt) {
     175            0 :             prompt.message = _("Please authenticate to gain administrative access");
     176            0 :             prompt.prompt = _("Password");
     177            0 :         }
     178              : 
     179            4 :         const apply = () => {
     180            4 :             proxy.Answer(value);
     181            4 :             setError(null);
     182            4 :             setBusy(true);
     183            4 :         };
     184              : 
     185            4 :         title = _("Switch to administrative access");
     186            4 :         body = (
     187            0 :             <Form isHorizontal onSubmit={event => { apply(); event.preventDefault(); return false }}>
     188            0 :                 { error && <Alert variant={errorVariant || 'danger'} isInline title={error} /> }
     189            0 :                 { prompt.message && <span>{prompt.message}</span> }
     190            4 :                 <FormGroup
     191            4 :                     fieldId="switch-to-admin-access-password"
     192            4 :                     label={prompt.prompt}
     193              :                 >
     194            4 :                     <TextInput
     195            4 :                         autoFocus // eslint-disable-line jsx-a11y/no-autofocus
     196            4 :                         id="switch-to-admin-access-password"
     197            4 :                         isDisabled={busy}
     198            4 :                         onChange={(_event, value) => setValue(value)}
     199            0 :                         type={!prompt.echo ? 'password' : 'text'}
     200            0 :                         validated={!error ? "default" : validated || "error"}
     201            4 :                         value={value}
     202            4 :                     />
     203            4 :                 </FormGroup>
     204            4 :             </Form>
     205              :         );
     206              : 
     207            4 :         footer = (
     208            4 :             <>
     209            4 :                 <Button variant='primary' onClick={apply} isDisabled={busy} isLoading={busy}>
     210            4 :                     {_("Authenticate")}
     211            4 :                 </Button>
     212            4 :                 <Button variant='link' className='btn-cancel' onClick={cancel}>
     213            4 :                     {_("Cancel")}
     214            4 :                 </Button>
     215            4 :             </>);
     216            0 :     } else if (message) {
     217            0 :         title = _("Administrative access");
     218            0 :         body = <p>{message}</p>;
     219            0 :         footer = (
     220            0 :             <Button variant="secondary" className='btn-cancel' onClick={cancel}>
     221            0 :                 {_("Close")}
     222            0 :             </Button>);
     223            0 :     } else if (error) {
     224            0 :         title_icon = "danger";
     225            0 :         title = _("Problem becoming administrator");
     226            0 :         body = <p>{error}</p>;
     227            0 :         footer = (
     228            0 :             <Button variant="secondary" className='btn-cancel' onClick={cancel}>
     229            0 :                 {_("Close")}
     230            0 :             </Button>);
     231            0 :     } else if (methods && method) {
     232            0 :         title = _("Switch to administrative access");
     233            0 :         body = (
     234            0 :             <Form isHorizontal>
     235            0 :                 <FormGroup fieldId="switch-to-admin-access-bridge-select"
     236            0 :                            label={_("Method")}>
     237            0 :                     <FormSelect id="switch-to-admin-access-bridge-select" value={method} onChange={(_, method) => setMethod(method)} isDisabled={busy}>
     238            0 :                         { methods.map(m => <FormSelectOption value={m} key={m}
     239            0 :                                                              label={_(proxy.Methods[m].v.label.v)} />) }
     240            0 :                     </FormSelect>
     241            0 :                 </FormGroup>
     242            0 :             </Form>);
     243              : 
     244            0 :         footer = (
     245            0 :             <>
     246            0 :                 <Button variant='primary' onClick={() => start(method)} isDisabled={busy} isLoading={busy}>
     247            0 :                     {_("Authenticate")}
     248            0 :                 </Button>
     249            0 :                 <Button variant='link' className='btn-cancel' onClick={cancel}>
     250            0 :                     {_("Cancel")}
     251            0 :                 </Button>
     252            0 :             </>);
     253            0 :     }
     254              : 
     255            4 :     if (body === null)
     256            4 :         return null;
     257              : 
     258            4 :     return (
     259            4 :         <Modal isOpen
     260            4 :                position="top"
     261            4 :                variant="medium"
     262            4 :                onClose={cancel}>
     263            4 :             <ModalHeader title={title}
     264            0 :                 {...title_icon && { titleIconVariant: title_icon }}
     265            4 :             />
     266            4 :             <ModalBody>
     267            4 :                 {body}
     268            4 :             </ModalBody>
     269            4 :             <ModalFooter>
     270            4 :                 {footer}
     271            4 :             </ModalFooter>
     272            4 :         </Modal>
     273              :     );
     274            4 : };
     275              : 
     276            2 : const LockDialog = ({
     277            2 :     proxy,
     278            2 :     host
     279            2 : } : {
     280              :     proxy: SuperuserProxy,
     281              :     host: string | undefined
     282            2 : }) => {
     283            2 :     const D = useDialogs();
     284            2 :     const [error, setError] = useState<string | null>(null);
     285              : 
     286            2 :     const apply = () => {
     287            2 :         setError(null);
     288            2 :         proxy.Stop()
     289            2 :                 .then(() => {
     290            2 :                     const key = host_superuser_storage_key(host);
     291            2 :                     if (key)
     292            2 :                         window.localStorage.setItem(key, "none");
     293            2 :                     D.close();
     294            2 :                 })
     295            0 :                 .catch(err => {
     296            0 :                     setError(err.toString());
     297            0 :                 });
     298            2 :     };
     299              : 
     300            2 :     const footer = (
     301            2 :         <ModalFooter>
     302            2 :             <Button variant='primary' onClick={apply}>
     303            2 :                 {_("Limit access")}
     304            2 :             </Button>
     305            2 :             <Button variant='link' className='btn-cancel' onClick={D.close}>
     306            2 :                 {_("Cancel")}
     307            2 :             </Button>
     308            2 :         </ModalFooter>
     309              :     );
     310              : 
     311            2 :     return (
     312            2 :         <Modal isOpen
     313            2 :                position="top" variant="medium"
     314            2 :                onClose={D.close}>
     315            2 :             <ModalHeader title={_("Switch to limited access")} />
     316            2 :             <ModalBody>
     317            2 :                 <Stack hasGutter>
     318            0 :                     {error && <ModalError dialogError={error} />}
     319            2 :                     <StackItem>
     320            2 :                         <p>{_("Limited access mode restricts administrative privileges. Some parts of the web console will have reduced functionality.")}</p>
     321            2 :                         <p>{_("Your browser will remember your access level across sessions.")}</p>
     322            2 :                     </StackItem>
     323            2 :                 </Stack>
     324            2 :             </ModalBody>
     325            2 :             {footer}
     326            2 :         </Modal>
     327              :     );
     328            2 : };
     329              : 
     330          128 : const SuperuserDialogs = ({
     331          128 :     superuser_proxy,
     332          128 :     host = undefined,
     333          128 :     create_trigger
     334          128 : } : {
     335              :     superuser_proxy: SuperuserProxy;
     336              :     host: string | undefined,
     337              :     create_trigger: (unlocked: boolean, onclick: () => void) => React.ReactNode;
     338          128 : }) => {
     339          128 :     const D = useDialogs();
     340          128 :     useEvent(superuser_proxy, "changed",
     341           17 :              () => {
     342           17 :                  const key = host_superuser_storage_key(host);
     343           16 :                  if (key) {
     344              :                      // Reset wanted state if we fail to gain admin privs.
     345              :                      // Failing to gain admin privs might take a noticeable
     346              :                      // time, and we don't want to suffer through the
     347              :                      // associated intermediate UI state on every login.
     348           16 :                      const want = window.localStorage.getItem(key);
     349            9 :                      if (superuser_proxy.Current == "none" && superuser_proxy.Current != want)
     350            2 :                          window.localStorage.setItem(key, superuser_proxy.Current);
     351           16 :                  }
     352           17 :              });
     353              : 
     354          127 :     const show = superuser_proxy.Current != "root" && superuser_proxy.Current != "init";
     355          128 :     const unlocked = superuser_proxy.Current != "none";
     356              : 
     357            4 :     function unlock() {
     358            4 :         D.show(<UnlockDialog proxy={superuser_proxy} host={host} />);
     359            4 :     }
     360              : 
     361            2 :     function lock() {
     362            2 :         D.show(<LockDialog proxy={superuser_proxy} host={host} />);
     363            2 :     }
     364              : 
     365          128 :     if (!show)
     366           25 :         return null;
     367              : 
     368           23 :     return create_trigger(unlocked, unlocked ? lock : unlock);
     369          128 : };
     370              : 
     371          120 : export const SuperuserIndicator = ({
     372          120 :     proxy,
     373          120 :     host
     374          120 : } : {
     375              :     proxy: SuperuserProxy | null,
     376              :     host?: string
     377          120 : }) => {
     378          120 :     if (!proxy || !proxy.valid)
     379          120 :         return null;
     380              : 
     381          118 :     function create_trigger(unlocked: boolean, onclick: () => void) {
     382          118 :         return (
     383           22 :             <Button variant="link" onClick={onclick} className={unlocked ? "ct-unlocked" : "ct-locked"}>
     384          118 :                 <span className="ct-lock-wrapper">
     385           33 :                     {!unlocked && <LockIcon />}
     386           22 :                     {unlocked ? _("Administrative access") : _("Limited access")}
     387          118 :                 </span>
     388          118 :             </Button>
     389              :         );
     390          118 :     }
     391              : 
     392          120 :     return <SuperuserDialogs superuser_proxy={proxy}
     393          120 :                              host={host}
     394          120 :                              create_trigger={create_trigger} />;
     395          120 : };
     396              : 
     397            8 : export const SuperuserButton = () => {
     398            8 :     const proxy = useObject(
     399            8 :         () => superuser_proxy(),
     400            8 :         null,
     401            8 :         []);
     402              : 
     403            8 :     const create_trigger = (unlocked: boolean, onclick: () => void) =>
     404            8 :         <Button onClick={onclick}>
     405            0 :             {unlocked ? _("Switch to limited access") : _("Turn on administrative access")}
     406            8 :         </Button>;
     407              : 
     408            8 :     return <SuperuserDialogs
     409            8 :                superuser_proxy={proxy}
     410            8 :                create_trigger={create_trigger}
     411            8 :                host={undefined} />;
     412            8 : };
        

Generated by: LCOV version 2.0-1