Line data Source code
1 : /*
2 : * Copyright (C) 2020 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 441 : import cockpit from "cockpit";
7 441 : 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 441 : const _ = cockpit.gettext;
23 :
24 376 : export function host_superuser_storage_key(host: string | undefined) {
25 376 : if (!host)
26 101 : host = cockpit.transport.host;
27 :
28 376 : const local_key = window.localStorage.getItem("superuser-key");
29 376 : if (host == "localhost")
30 78 : return local_key;
31 93 : else if (host.indexOf("@") >= 0)
32 70 : return "superuser:" + host;
33 89 : else if (local_key)
34 68 : return local_key + "@" + host;
35 : else
36 68 : return null;
37 376 : }
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 435 : export function superuser_proxy(bus?: cockpit.DBusClient) {
66 435 : if (!bus)
67 160 : bus = cockpit.dbus(null, { bus: "internal" });
68 435 : return bus.proxy("cockpit.Superuser", "/superuser") as SuperuserProxy;
69 435 : }
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 378 : const SuperuserDialogs = ({
331 378 : superuser_proxy,
332 378 : host = undefined,
333 378 : create_trigger
334 378 : } : {
335 : superuser_proxy: SuperuserProxy;
336 : host: string | undefined,
337 : create_trigger: (unlocked: boolean, onclick: () => void) => React.ReactNode;
338 378 : }) => {
339 378 : const D = useDialogs();
340 378 : useEvent(superuser_proxy, "changed",
341 74 : () => {
342 74 : const key = host_superuser_storage_key(host);
343 73 : 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 73 : const want = window.localStorage.getItem(key);
349 50 : if (superuser_proxy.Current == "none" && superuser_proxy.Current != want)
350 26 : window.localStorage.setItem(key, superuser_proxy.Current);
351 73 : }
352 74 : });
353 :
354 377 : const show = superuser_proxy.Current != "root" && superuser_proxy.Current != "init";
355 378 : 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 378 : if (!show)
366 91 : return null;
367 :
368 93 : return create_trigger(unlocked, unlocked ? lock : unlock);
369 378 : };
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 40 : export const SuperuserButton = () => {
398 40 : const proxy = useObject(
399 40 : () => superuser_proxy(),
400 40 : null,
401 40 : []);
402 :
403 40 : const create_trigger = (unlocked: boolean, onclick: () => void) =>
404 40 : <Button onClick={onclick}>
405 5 : {unlocked ? _("Switch to limited access") : _("Turn on administrative access")}
406 40 : </Button>;
407 :
408 40 : return <SuperuserDialogs
409 40 : superuser_proxy={proxy}
410 40 : create_trigger={create_trigger}
411 40 : host={undefined} />;
412 40 : };
|