Line data Source code
1 : /*
2 : * Copyright (C) 2020 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 163 : import cockpit from "cockpit";
7 163 : 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 163 : 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 5 : function sudo_polish(msg: string | null): string | null {
42 5 : if (!msg)
43 5 : return msg;
44 :
45 5 : msg = msg.replace(/^\[sudo(: authenticate)?] /, "");
46 5 : msg = msg[0].toUpperCase() + msg.slice(1);
47 :
48 5 : return msg;
49 5 : }
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 158 : export function superuser_proxy(bus?: cockpit.DBusClient) {
66 158 : if (!bus)
67 57 : bus = cockpit.dbus(null, { bus: "internal" });
68 158 : return bus.proxy("cockpit.Superuser", "/superuser") as SuperuserProxy;
69 158 : }
70 :
71 5 : const UnlockDialog = ({
72 5 : proxy,
73 5 : host
74 5 : } : {
75 : proxy: SuperuserProxy,
76 : host: string | undefined
77 5 : }) => {
78 5 : const D = useDialogs();
79 5 : useInit(init, [proxy, host]);
80 :
81 5 : const [methods, setMethods] = useState<string[] | null>(null);
82 5 : const [method, setMethod] = useState<string | false>(false);
83 5 : const [busy, setBusy] = useState(false);
84 5 : const [cancel, setCancel] = useState(() => D.close);
85 5 : const [prompt, setPrompt] = useState<{ message: string, prompt: string, echo: boolean } | null>(null);
86 5 : const [message, setMessage] = useState<string | null>(null);
87 5 : const [error, setError] = useState<string | null>(null);
88 5 : const [errorVariant, setErrorVariant] = useState<"danger" | "warning" | null>(null);
89 5 : const [value, setValue] = useState("");
90 :
91 5 : function start(method: string) {
92 5 : setBusy(true);
93 0 : setCancel(() => () => {
94 0 : proxy.Stop();
95 0 : D.close();
96 0 : });
97 :
98 5 : let did_prompt = false;
99 :
100 5 : const onprompt = (_event: Event, message: string, prompt: string, def: string, echo: boolean, error: string) => {
101 5 : setBusy(false);
102 5 : setPrompt({
103 5 : message: sudo_polish(message),
104 5 : prompt: sudo_polish(prompt),
105 5 : echo
106 5 : });
107 5 : setValue(def);
108 :
109 1 : if (error) {
110 1 : setError(sudo_polish(error));
111 0 : setErrorVariant(did_prompt ? 'danger' : 'warning');
112 1 : }
113 :
114 5 : did_prompt = true;
115 5 : };
116 :
117 5 : proxy.addEventListener("Prompt", onprompt);
118 5 : proxy.Start(method)
119 5 : .then(() => {
120 5 : proxy.removeEventListener("Prompt", onprompt);
121 :
122 5 : const key = host_superuser_storage_key(host);
123 5 : if (key)
124 5 : window.localStorage.setItem(key, method);
125 5 : if (did_prompt) {
126 5 : 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 5 : })
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 5 : }
146 :
147 5 : function init() {
148 5 : 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 5 : const ids = Object.keys(proxy.Methods);
153 5 : 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 5 : } else
162 0 : start(proxy.Bridges[0]);
163 5 : });
164 5 : }
165 :
166 0 : const validated = errorVariant == "danger" ? "error" : errorVariant;
167 :
168 5 : let title = null;
169 5 : let title_icon: null | "danger" = null;
170 5 : let body = null;
171 5 : let footer = null;
172 :
173 5 : 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 5 : const apply = () => {
180 5 : proxy.Answer(value);
181 5 : setError(null);
182 5 : setBusy(true);
183 5 : };
184 :
185 5 : title = _("Switch to administrative access");
186 5 : 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 5 : <FormGroup
191 5 : fieldId="switch-to-admin-access-password"
192 5 : label={prompt.prompt}
193 : >
194 5 : <TextInput
195 5 : autoFocus // eslint-disable-line jsx-a11y/no-autofocus
196 5 : id="switch-to-admin-access-password"
197 5 : isDisabled={busy}
198 5 : onChange={(_event, value) => setValue(value)}
199 0 : type={!prompt.echo ? 'password' : 'text'}
200 0 : validated={!error ? "default" : validated || "error"}
201 5 : value={value}
202 5 : />
203 5 : </FormGroup>
204 5 : </Form>
205 : );
206 :
207 5 : footer = (
208 5 : <>
209 5 : <Button variant='primary' onClick={apply} isDisabled={busy} isLoading={busy}>
210 5 : {_("Authenticate")}
211 5 : </Button>
212 5 : <Button variant='link' className='btn-cancel' onClick={cancel}>
213 5 : {_("Cancel")}
214 5 : </Button>
215 5 : </>);
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 5 : if (body === null)
256 5 : return null;
257 :
258 5 : return (
259 5 : <Modal isOpen
260 5 : position="top"
261 5 : variant="medium"
262 5 : onClose={cancel}>
263 5 : <ModalHeader title={title}
264 0 : {...title_icon && { titleIconVariant: title_icon }}
265 5 : />
266 5 : <ModalBody>
267 5 : {body}
268 5 : </ModalBody>
269 5 : <ModalFooter>
270 5 : {footer}
271 5 : </ModalFooter>
272 5 : </Modal>
273 : );
274 5 : };
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 5 : function unlock() {
358 5 : D.show(<UnlockDialog proxy={superuser_proxy} host={host} />);
359 5 : }
360 :
361 2 : function lock() {
362 2 : D.show(<LockDialog proxy={superuser_proxy} host={host} />);
363 2 : }
364 :
365 128 : if (!show)
366 26 : return null;
367 :
368 24 : 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 23 : <Button variant="link" onClick={onclick} className={unlocked ? "ct-unlocked" : "ct-locked"}>
384 118 : <span className="ct-lock-wrapper">
385 33 : {!unlocked && <LockIcon />}
386 23 : {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 : };
|