* Copyright (C) 2020 Red Hat, Inc.
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import cockpit from "cockpit";
import React, { useState } from "react";
import { useObject, useInit, useEvent } from "hooks";
import { useDialogs } from "dialogs.jsx";
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js";
import {
Modal, ModalBody, ModalFooter, ModalHeader
} from '@patternfly/react-core/dist/esm/components/Modal/index.js';
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput/index.js";
import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js";
import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
import { ModalError } from 'cockpit-components-inline-notification.jsx';
import { LockIcon } from '@patternfly/react-icons';
const _ = cockpit.gettext;
export function host_superuser_storage_key(host: string | undefined) {
if (!host)
host = cockpit.transport.host;
const local_key = window.localStorage.getItem("superuser-key");
if (host === "localhost")
return local_key;
else if (host.indexOf("@") >= 0)
return "superuser:" + host;
else if (local_key)
return local_key + "@" + host;
else
return null;
}
function sudo_polish(msg: string): string;
function sudo_polish(msg: null): null;
function sudo_polish(msg: string | null): string | null {
if (!msg)
return msg;
msg = msg.replace(/^\[sudo(: authenticate)?] /, "");
msg = msg[0].toUpperCase() + msg.slice(1);
return msg;
}
interface Method {
v: { label: { v: string; } };
}
export interface SuperuserProxy extends cockpit.DBusProxy {
Current: string;
Bridges: string[];
Methods: Record<string, Method>;
Start(method: string): Promise<void>;
Stop(): Promise<void>;
Answer(val: string): Promise<void>;
}
export function superuser_proxy(bus?: cockpit.DBusClient) {
if (!bus)
bus = cockpit.dbus(null, { bus: "internal" });
return bus.proxy("cockpit.Superuser", "/superuser") as SuperuserProxy;
}
const UnlockDialog = ({
proxy,
host
} : {
proxy: SuperuserProxy,
host: string | undefined
}) => {
const D = useDialogs();
useInit(init, [proxy, host]);
const [methods, setMethods] = useState<string[] | null>(null);
const [method, setMethod] = useState<string | false>(false);
const [busy, setBusy] = useState(false);
const [cancel, setCancel] = useState(() => D.close);
const [prompt, setPrompt] = useState<{ message: string, prompt: string, echo: boolean } | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorVariant, setErrorVariant] = useState<"danger" | "warning" | null>(null);
const [value, setValue] = useState("");
function start(method: string) {
setBusy(true);
setCancel(() => () => {
proxy.Stop();
D.close();
});
let did_prompt = false;
const onprompt = (_event: Event, message: string, prompt: string, def: string, echo: boolean, error: string) => {
setBusy(false);
setPrompt({
message: sudo_polish(message),
prompt: sudo_polish(prompt),
echo
});
setValue(def);
if (error) {
setError(sudo_polish(error));
setErrorVariant(did_prompt ? 'danger' : 'warning');
}
did_prompt = true;
};
proxy.addEventListener("Prompt", onprompt);
proxy.Start(method)
.then(() => {
proxy.removeEventListener("Prompt", onprompt);
const key = host_superuser_storage_key(host);
if (key)
window.localStorage.setItem(key, method);
if (did_prompt) {
D.close();
} else {
setBusy(false);
setPrompt(null);
setMessage(_("You now have administrative access."));
setCancel(() => D.close);
}
})
.catch((err: cockpit.BasicError) => {
console.warn(err);
proxy.removeEventListener("Prompt", onprompt);
if (err && err.message !== "cancelled") {
setBusy(false);
setPrompt(null);
setError(sudo_polish(err.toString()));
setCancel(() => D.close);
} else
D.close();
});
}
function init() {
return proxy.Stop().finally(() => {
if (proxy.Bridges.length === 0) {
setError(_("No methods to gain administrative access are available (sudo -A, pkexec)."));
} else if (proxy.Methods) {
const ids = Object.keys(proxy.Methods);
if (ids.length === 0)
start(proxy.Bridges[0]);
else if (ids.length === 1)
start(ids[0]);
else {
setMethods(ids);
setMethod(ids[0]);
}
} else
start(proxy.Bridges[0]);
});
}
const validated = errorVariant === "danger" ? "error" : errorVariant;
let title = null;
let title_icon: null | "danger" = null;
let body = null;
let footer = null;
if (prompt) {
if (!prompt.message && !prompt.prompt) {
prompt.message = _("Please authenticate to gain administrative access");
prompt.prompt = _("Password");
}
const apply = () => {
proxy.Answer(value);
setError(null);
setBusy(true);
};
title = _("Switch to administrative access");
body = (
<Form isHorizontal onSubmit={event => { apply(); event.preventDefault(); return false }}>
{ error && <Alert variant={errorVariant || 'danger'} isInline title={error} /> }
{ prompt.message && <span>{prompt.message}</span> }
<FormGroup
fieldId="switch-to-admin-access-password"
label={prompt.prompt}
>
<TextInput
autoFocus
id="switch-to-admin-access-password"
isDisabled={busy}
onChange={(_event, value) => setValue(value)}
type={!prompt.echo ? 'password' : 'text'}
validated={!error ? "default" : validated || "error"}
value={value}
/>
</FormGroup>
</Form>
);
footer = (
<>
<Button variant='primary' onClick={apply} isDisabled={busy} isLoading={busy}>
{_("Authenticate")}
</Button>
<Button variant='link' className='btn-cancel' onClick={cancel}>
{_("Cancel")}
</Button>
</>);
} else if (message) {
title = _("Administrative access");
body = <p>{message}</p>;
footer = (
<Button variant="secondary" className='btn-cancel' onClick={cancel}>
{_("Close")}
</Button>);
} else if (error) {
title_icon = "danger";
title = _("Problem becoming administrator");
body = <p>{error}</p>;
footer = (
<Button variant="secondary" className='btn-cancel' onClick={cancel}>
{_("Close")}
</Button>);
} else if (methods && method) {
title = _("Switch to administrative access");
body = (
<Form isHorizontal>
<FormGroup fieldId="switch-to-admin-access-bridge-select"
label={_("Method")}>
<FormSelect id="switch-to-admin-access-bridge-select" value={method} onChange={(_, method) => setMethod(method)} isDisabled={busy}>
{ methods.map(m => <FormSelectOption value={m} key={m}
label={_(proxy.Methods[m].v.label.v)} />) }
</FormSelect>
</FormGroup>
</Form>);
footer = (
<>
<Button variant='primary' onClick={() => start(method)} isDisabled={busy} isLoading={busy}>
{_("Authenticate")}
</Button>
<Button variant='link' className='btn-cancel' onClick={cancel}>
{_("Cancel")}
</Button>
</>);
}
if (body === null)
return null;
return (
<Modal isOpen
position="top"
variant="medium"
onClose={cancel}>
<ModalHeader title={title}
{...title_icon && { titleIconVariant: title_icon }}
/>
<ModalBody>
{body}
</ModalBody>
<ModalFooter>
{footer}
</ModalFooter>
</Modal>
);
};
const LockDialog = ({
proxy,
host
} : {
proxy: SuperuserProxy,
host: string | undefined
}) => {
const D = useDialogs();
const [error, setError] = useState<string | null>(null);
const apply = () => {
setError(null);
proxy.Stop()
.then(() => {
const key = host_superuser_storage_key(host);
if (key)
window.localStorage.setItem(key, "none");
D.close();
})
.catch(err => {
setError(err.toString());
});
};
const footer = (
<ModalFooter>
<Button variant='primary' onClick={apply}>
{_("Limit access")}
</Button>
<Button variant='link' className='btn-cancel' onClick={D.close}>
{_("Cancel")}
</Button>
</ModalFooter>
);
return (
<Modal isOpen
position="top" variant="medium"
onClose={D.close}>
<ModalHeader title={_("Switch to limited access")} />
<ModalBody>
<Stack hasGutter>
{error && <ModalError dialogError={error} />}
<StackItem>
<p>{_("Limited access mode restricts administrative privileges. Some parts of the web console will have reduced functionality.")}</p>
<p>{_("Your browser will remember your access level across sessions.")}</p>
</StackItem>
</Stack>
</ModalBody>
{footer}
</Modal>
);
};
const SuperuserDialogs = ({
superuser_proxy,
host = undefined,
create_trigger
} : {
superuser_proxy: SuperuserProxy;
host: string | undefined,
create_trigger: (unlocked: boolean, onclick: () => void) => React.ReactNode;
}) => {
const D = useDialogs();
useEvent(superuser_proxy, "changed",
() => {
const key = host_superuser_storage_key(host);
if (key) {
const want = window.localStorage.getItem(key);
if (superuser_proxy.Current === "none" && superuser_proxy.Current !== want)
window.localStorage.setItem(key, superuser_proxy.Current);
}
});
const show = superuser_proxy.Current !== "root" && superuser_proxy.Current !== "init";
const unlocked = superuser_proxy.Current !== "none";
function unlock() {
D.show(<UnlockDialog proxy={superuser_proxy} host={host} />);
}
function lock() {
D.show(<LockDialog proxy={superuser_proxy} host={host} />);
}
if (!show)
return null;
return create_trigger(unlocked, unlocked ? lock : unlock);
};
export const SuperuserIndicator = ({
proxy,
host
} : {
proxy: SuperuserProxy | null,
host?: string
}) => {
if (!proxy || !proxy.valid)
return null;
function create_trigger(unlocked: boolean, onclick: () => void) {
return (
<Button variant="link" onClick={onclick} className={unlocked ? "ct-unlocked" : "ct-locked"}>
<span className="ct-lock-wrapper">
{!unlocked && <LockIcon />}
{unlocked ? _("Administrative access") : _("Limited access")}
</span>
</Button>
);
}
return <SuperuserDialogs superuser_proxy={proxy}
host={host}
create_trigger={create_trigger} />;
};
export const SuperuserButton = () => {
const proxy = useObject(
() => superuser_proxy(),
null,
[]);
const create_trigger = (unlocked: boolean, onclick: () => void) =>
<Button onClick={onclick}>
{unlocked ? _("Switch to limited access") : _("Turn on administrative access")}
</Button>;
return <SuperuserDialogs
superuser_proxy={proxy}
create_trigger={create_trigger}
host={undefined} />;
};