LCOV - code coverage report
Current view: top level - pkg/lib - superuser-dialogs.tsx Coverage Total Hit
Test: cockpit Lines: 98.1 % 316 310
Test Date: 2026-07-13 10:00:01

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

Generated by: LCOV version 2.0-1