瀏覽代碼

support Authentication (#804)

* support Authentication
add Login control
connected to taipy-enterprise #313 & #238

* lint

* fix test

* Update taipy/gui/viselements.json

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

* Update taipy/gui/viselements.json

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

* Fab's comment

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>
Fred Lefévère-Laoide 1 年之前
父節點
當前提交
308f2d69b1

+ 68 - 0
frontend/taipy-gui/src/components/Taipy/Login.spec.tsx

@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+import React from "react";
+import { getByLabelText, getByPlaceholderText, render } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import userEvent from "@testing-library/user-event";
+
+import Login from "./Login";
+import { INITIAL_STATE, TaipyState } from "../../context/taipyReducers";
+import { TaipyContext } from "../../context/taipyContext";
+
+describe("Login Component", () => {
+    it("renders", async () => {
+        const { getByText } = render(<Login />);
+        const elt = getByText("Log-in");
+        expect(elt.tagName).toBe("H2");
+    });
+    it("uses the class", async () => {
+        const { getByText } = render(<Login className="taipy-login" />);
+        const elt = getByText("Log-in");
+        expect(elt.closest(".taipy-login")).not.toBeNull();
+    });
+    it("dispatch a well formed message", async () => {
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByText, getByLabelText } = render(
+            <TaipyContext.Provider value={{ state, dispatch }}>
+                <Login id="logg" onAction="action" />
+            </TaipyContext.Provider>
+        );
+        const user = "user";
+        const elt = getByText("Log in");
+        expect(elt).toBeDisabled();
+        const uElt = getByText("User name");
+        expect(uElt.tagName).toBe("LABEL");
+        const uInput = uElt.parentElement?.querySelector("input");
+        expect(uInput).not.toBeNull();
+        if (!uInput) {
+            return;
+        }
+        await userEvent.type(uInput, user);
+        const pElt = getByText("Password");
+        const pInput = pElt.parentElement?.querySelector("input");
+        expect(pInput).not.toBeNull();
+        if (!pInput) {
+            return;
+        }
+        await userEvent.type(pInput, user);
+        expect(elt).not.toBeDisabled();
+        await userEvent.click(elt);
+        expect(dispatch).toHaveBeenCalledWith({
+            name: "logg",
+            payload: { action: "action", args: [user, user, ""] },
+            type: "SEND_ACTION_ACTION",
+        });
+    });
+});

+ 175 - 0
frontend/taipy-gui/src/components/Taipy/Login.tsx

@@ -0,0 +1,175 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+import React, { ChangeEvent, KeyboardEvent, MouseEvent, useCallback, useEffect, useState } from "react";
+import Button from "@mui/material/Button";
+import CircularProgress from "@mui/material/CircularProgress";
+import DialogTitle from "@mui/material/DialogTitle";
+import Dialog from "@mui/material/Dialog";
+import DialogActions from "@mui/material/DialogActions";
+import DialogContent from "@mui/material/DialogContent";
+import DialogContentText from "@mui/material/DialogContentText";
+import FormControl from "@mui/material/FormControl";
+import InputAdornment from "@mui/material/InputAdornment";
+import InputLabel from "@mui/material/InputLabel";
+import OutlinedInput from "@mui/material/OutlinedInput";
+import TextField from "@mui/material/TextField";
+import IconButton from "@mui/material/IconButton";
+import CloseIcon from "@mui/icons-material/Close";
+import Visibility from "@mui/icons-material/Visibility";
+import VisibilityOff from "@mui/icons-material/VisibilityOff";
+import { SxProps, Theme } from "@mui/system";
+
+import { createSendActionNameAction } from "../../context/taipyReducers";
+import { TaipyBaseProps, getSuffixedClassNames } from "./utils";
+import { useClassNames, useDispatch, useModule } from "../../utils/hooks";
+
+// allow only one instance of this component
+let nbLogins = 0;
+
+interface LoginProps extends TaipyBaseProps {
+    title?: string;
+    onAction?: string;
+    defaultMessage?: string;
+    message?: string;
+}
+
+const closeSx: SxProps<Theme> = {
+    color: (theme: Theme) => theme.palette.grey[500],
+    marginTop: "-0.6em",
+    marginLeft: "auto",
+    alignSelf: "start",
+};
+const titleSx = { m: 0, p: 2, display: "flex", paddingRight: "0.1em" };
+
+const Login = (props: LoginProps) => {
+    const { id, title = "Log-in", onAction = "on_login", message, defaultMessage } = props;
+    const dispatch = useDispatch();
+    const module = useModule();
+    const [onlyOne, setOnlyOne] = useState(false);
+    const [user, setUser] = useState("");
+    const [password, setPassword] = useState("");
+    const [showPassword, setShowPassword] = useState(false);
+    const [showProgress, setShowProgress] = useState(false);
+
+    const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
+
+    const handleAction = useCallback(
+        (evt: MouseEvent<HTMLElement>) => {
+            if (!user || !password) {
+                return;
+            }
+            const { close } = evt?.currentTarget.dataset || {};
+            const args = close
+                ? ["", "", document.location.pathname.substring(1)]
+                : [user, password, document.location.pathname.substring(1)];
+            setShowProgress(true);
+            dispatch(createSendActionNameAction(id, module, onAction, ...args));
+        },
+        [user, password, dispatch, id, onAction, module]
+    );
+
+    const changeInput = useCallback((evt: ChangeEvent<HTMLInputElement>) => {
+        const { input } = evt.currentTarget.parentElement?.parentElement?.dataset || {};
+        input == "user" ? setUser(evt.currentTarget.value) : setPassword(evt.currentTarget.value);
+    }, []);
+
+    const handleClickShowPassword = useCallback(() => setShowPassword((show) => !show), []);
+
+    const handleMouseDownPassword = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => event.preventDefault(),
+        []
+    );
+
+    const handleEnter = useCallback(
+        (evt: KeyboardEvent<HTMLInputElement>) => {
+            if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && evt.key == "Enter") {
+                handleAction(undefined as unknown as MouseEvent<HTMLElement>);
+                evt.preventDefault();
+            }
+        },
+        [handleAction]
+    );
+
+    useEffect(() => {
+        nbLogins++;
+        if (nbLogins === 1) {
+            setOnlyOne(true);
+        }
+        return () => {
+            nbLogins--;
+        };
+    }, []);
+
+    return onlyOne ? (
+        <Dialog id={id} onClose={handleAction} open={true} className={className}>
+            <DialogTitle sx={titleSx}>
+                {title}
+                <IconButton aria-label="close" onClick={handleAction} sx={closeSx} title="close" data-close>
+                    <CloseIcon />
+                </IconButton>
+            </DialogTitle>
+
+            <DialogContent dividers>
+                <TextField
+                    variant="outlined"
+                    label="User name"
+                    required
+                    fullWidth
+                    margin="dense"
+                    className={getSuffixedClassNames(className, "-user")}
+                    value={user}
+                    onChange={changeInput}
+                    data-input="user"
+                    onKeyDown={handleEnter}
+                ></TextField>
+                <FormControl variant="outlined" data-input="password" required>
+                    <InputLabel htmlFor="taipy-login-password">Password</InputLabel>
+                    <OutlinedInput
+                        id="taipy-login-password"
+                        type={showPassword ? "text" : "password"}
+                        value={password}
+                        onChange={changeInput}
+                        endAdornment={
+                            <InputAdornment position="end">
+                                <IconButton
+                                    aria-label="toggle password visibility"
+                                    onClick={handleClickShowPassword}
+                                    onMouseDown={handleMouseDownPassword}
+                                    edge="end"
+                                >
+                                    {showPassword ? <VisibilityOff /> : <Visibility />}
+                                </IconButton>
+                            </InputAdornment>
+                        }
+                        label="Password"
+                        onKeyDown={handleEnter}
+                    />
+                </FormControl>
+                <DialogContentText>{message || defaultMessage}</DialogContentText>
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant="outlined"
+                    className={getSuffixedClassNames(className, "-button")}
+                    onClick={handleAction}
+                    disabled={!user || !password || showProgress}
+                >
+                    {showProgress ? <CircularProgress size="2rem" /> : "Log in"}
+                </Button>
+            </DialogActions>
+        </Dialog>
+    ) : null;
+};
+
+export default Login;

+ 1 - 1
frontend/taipy-gui/src/components/Taipy/Navigate.tsx

@@ -31,7 +31,7 @@ const Navigate = ({ to, params, tab, force }: NavigateProps) => {
     useEffect(() => {
     useEffect(() => {
         if (to) {
         if (to) {
             const tos = to === "/" ? to : "/" + to;
             const tos = to === "/" ? to : "/" + to;
-            const searchParams = new URLSearchParams(params);
+            const searchParams = new URLSearchParams(params || "");
             if (Object.keys(state.locations || {}).some((route) => tos === route)) {
             if (Object.keys(state.locations || {}).some((route) => tos === route)) {
                 const searchParamsLocation = new URLSearchParams(location.search);
                 const searchParamsLocation = new URLSearchParams(location.search);
                 if (force && location.pathname === tos && searchParamsLocation.toString() === searchParams.toString()) {
                 if (force && location.pathname === tos && searchParamsLocation.toString() === searchParams.toString()) {

+ 2 - 0
frontend/taipy-gui/src/components/Taipy/index.ts

@@ -24,6 +24,7 @@ import FileSelector from "./FileSelector";
 import Image from "./Image";
 import Image from "./Image";
 import Indicator from "./Indicator";
 import Indicator from "./Indicator";
 import Input from "./Input";
 import Input from "./Input";
+import Login from "./Login";
 import Layout from "./Layout";
 import Layout from "./Layout";
 import Link from "./Link";
 import Link from "./Link";
 import MenuCtl from "./MenuCtl";
 import MenuCtl from "./MenuCtl";
@@ -56,6 +57,7 @@ export const getRegisteredComponents = () => {
             Image: Image,
             Image: Image,
             Indicator: Indicator,
             Indicator: Indicator,
             Input: Input,
             Input: Input,
+            Login: Login,
             Layout: Layout,
             Layout: Layout,
             MenuCtl: MenuCtl,
             MenuCtl: MenuCtl,
             NavBar: NavBar,
             NavBar: NavBar,

+ 2 - 0
frontend/taipy-gui/src/extensions/exports.ts

@@ -13,6 +13,7 @@
 
 
 import Chart from "../components/Taipy/Chart";
 import Chart from "../components/Taipy/Chart";
 import Dialog from "../components/Taipy/Dialog";
 import Dialog from "../components/Taipy/Dialog";
+import Login from "../components/Taipy/Login";
 import Router from "../components/Router";
 import Router from "../components/Router";
 import Table from "../components/Taipy/Table";
 import Table from "../components/Taipy/Table";
 import { useLovListMemo, LoV, LoVElt } from "../components/Taipy/lovUtils";
 import { useLovListMemo, LoV, LoVElt } from "../components/Taipy/lovUtils";
@@ -32,6 +33,7 @@ import {
 export {
 export {
     Chart,
     Chart,
     Dialog,
     Dialog,
+    Login,
     Router,
     Router,
     Table,
     Table,
     TaipyContext as Context,
     TaipyContext as Context,

+ 2 - 2
taipy/gui/_page.py

@@ -29,14 +29,14 @@ class _Page(object):
         self._route: t.Optional[str] = None
         self._route: t.Optional[str] = None
         self._head: t.Optional[list] = None
         self._head: t.Optional[list] = None
 
 
-    def render(self, gui: Gui):
+    def render(self, gui: Gui, silent: t.Optional[bool] = False):
         if self._renderer is None:
         if self._renderer is None:
             raise RuntimeError(f"Can't render page {self._route}: no renderer found")
             raise RuntimeError(f"Can't render page {self._route}: no renderer found")
         with warnings.catch_warnings(record=True) as w:
         with warnings.catch_warnings(record=True) as w:
             warnings.resetwarnings()
             warnings.resetwarnings()
             with gui._set_locals_context(self._renderer._get_module_name()):
             with gui._set_locals_context(self._renderer._get_module_name()):
                 self._rendered_jsx = self._renderer.render(gui)
                 self._rendered_jsx = self._renderer.render(gui)
-            if w:
+            if not silent and w:
                 s = "\033[1;31m\n"
                 s = "\033[1;31m\n"
                 s += (
                 s += (
                     message := f"--- {len(w)} warning(s) were found for page '{'/' if self._route == gui._get_root_page_name() else self._route}' {self._renderer._get_content_detail(gui)} ---\n"  # noqa: E501
                     message := f"--- {len(w)} warning(s) were found for page '{'/' if self._route == gui._get_root_page_name() else self._route}' {self._renderer._get_content_detail(gui)} ---\n"  # noqa: E501

+ 23 - 10
taipy/gui/_renderers/factory.py

@@ -43,6 +43,7 @@ class _Factory:
         "indicator": "display",
         "indicator": "display",
         "input": "value",
         "input": "value",
         "layout": "columns",
         "layout": "columns",
+        "login": "title",
         "menu": "lov",
         "menu": "lov",
         "navbar": "value",
         "navbar": "value",
         "number": "value",
         "number": "value",
@@ -298,6 +299,17 @@ class _Factory:
                 ("gap",),
                 ("gap",),
             ]
             ]
         ),
         ),
+        "login": lambda gui, control_type, attrs: _Builder(
+            gui=gui, control_type=control_type, element_name="Login", attributes=attrs, default_value=None
+        )
+        .set_value_and_default(default_val="Log-in")
+        .set_attributes(
+            [
+                ("id",),
+                ("message", PropertyType.dynamic_string),
+                ("on_action", PropertyType.function, "on_login"),
+            ]
+        ),
         "menu": lambda gui, control_type, attrs: _Builder(
         "menu": lambda gui, control_type, attrs: _Builder(
             gui=gui,
             gui=gui,
             control_type=control_type,
             control_type=control_type,
@@ -611,15 +623,16 @@ class _Factory:
         name = name[len(_Factory.__TAIPY_NAME_SPACE) :] if name.startswith(_Factory.__TAIPY_NAME_SPACE) else name
         name = name[len(_Factory.__TAIPY_NAME_SPACE) :] if name.startswith(_Factory.__TAIPY_NAME_SPACE) else name
         builder = _Factory.__CONTROL_BUILDERS.get(name)
         builder = _Factory.__CONTROL_BUILDERS.get(name)
         built = None
         built = None
-        if builder is None:
-            lib, element_name, element = _Factory.__get_library_element(name)
-            if lib:
-                from ..extension.library import Element
+        with gui._get_autorization():
+            if builder is None:
+                lib, element_name, element = _Factory.__get_library_element(name)
+                if lib:
+                    from ..extension.library import Element
 
 
-                if isinstance(element, Element):
-                    return element._call_builder(element_name, gui, all_properties, lib, is_html)
-        else:
-            built = builder(gui, name, all_properties)
-        if isinstance(built, _Builder):
-            return built._build_to_string() if is_html else built.el
+                    if isinstance(element, Element):
+                        return element._call_builder(element_name, gui, all_properties, lib, is_html)
+            else:
+                built = builder(gui, name, all_properties)
+            if isinstance(built, _Builder):
+                return built._build_to_string() if is_html else built.el
         return None
         return None

+ 25 - 21
taipy/gui/gui.py

@@ -595,26 +595,27 @@ class Gui:
             self.__set_client_id_in_context(expected_client_id)
             self.__set_client_id_in_context(expected_client_id)
             g.ws_client_id = expected_client_id
             g.ws_client_id = expected_client_id
             with self._set_locals_context(message.get("module_context") or None):
             with self._set_locals_context(message.get("module_context") or None):
-                payload = message.get("payload", {})
-                if msg_type == _WsType.UPDATE.value:
-                    self.__front_end_update(
-                        str(message.get("name")),
-                        payload.get("value"),
-                        message.get("propagate", True),
-                        payload.get("relvar"),
-                        payload.get("on_change"),
-                    )
-                elif msg_type == _WsType.ACTION.value:
-                    self.__on_action(message.get("name"), message.get("payload"))
-                elif msg_type == _WsType.DATA_UPDATE.value:
-                    self.__request_data_update(str(message.get("name")), message.get("payload"))
-                elif msg_type == _WsType.REQUEST_UPDATE.value:
-                    self.__request_var_update(message.get("payload"))
-                elif msg_type == _WsType.GET_MODULE_CONTEXT.value:
-                    self.__handle_ws_get_module_context(payload)
-                elif msg_type == _WsType.GET_VARIABLES.value:
-                    self.__handle_ws_get_variables()
-            self.__send_ack(message.get("ack_id"))
+                with self._get_autorization():
+                    payload = message.get("payload", {})
+                    if msg_type == _WsType.UPDATE.value:
+                        self.__front_end_update(
+                            str(message.get("name")),
+                            payload.get("value"),
+                            message.get("propagate", True),
+                            payload.get("relvar"),
+                            payload.get("on_change"),
+                        )
+                    elif msg_type == _WsType.ACTION.value:
+                        self.__on_action(message.get("name"), message.get("payload"))
+                    elif msg_type == _WsType.DATA_UPDATE.value:
+                        self.__request_data_update(str(message.get("name")), message.get("payload"))
+                    elif msg_type == _WsType.REQUEST_UPDATE.value:
+                        self.__request_var_update(message.get("payload"))
+                    elif msg_type == _WsType.GET_MODULE_CONTEXT.value:
+                        self.__handle_ws_get_module_context(payload)
+                    elif msg_type == _WsType.GET_VARIABLES.value:
+                        self.__handle_ws_get_variables()
+                self.__send_ack(message.get("ack_id"))
         except Exception as e:  # pragma: no cover
         except Exception as e:  # pragma: no cover
             _warn(f"Decoding Message has failed: {message}", e)
             _warn(f"Decoding Message has failed: {message}", e)
 
 
@@ -1948,7 +1949,7 @@ class Gui:
                     if isinstance(page._renderer, CustomPage):
                     if isinstance(page._renderer, CustomPage):
                         self._bind_custom_page_variables(page._renderer, self._get_client_id())
                         self._bind_custom_page_variables(page._renderer, self._get_client_id())
                     else:
                     else:
-                        page.render(self)
+                        page.render(self, silent=True)
 
 
     def _get_navigated_page(self, page_name: str) -> t.Any:
     def _get_navigated_page(self, page_name: str) -> t.Any:
         nav_page = page_name
         nav_page = page_name
@@ -2429,3 +2430,6 @@ class Gui:
         if hasattr(self, "_server") and hasattr(self._server, "_thread") and self._server._is_running:
         if hasattr(self, "_server") and hasattr(self._server, "_thread") and self._server._is_running:
             self._server.stop_thread()
             self._server.stop_thread()
             _TaipyLogger._get_logger().info("Gui server has been stopped.")
             _TaipyLogger._get_logger().info("Gui server has been stopped.")
+
+    def _get_autorization(self, client_id: t.Optional[str] = None, system: t.Optional[bool] = False):
+        return contextlib.nullcontext()

+ 2 - 1
taipy/gui/utils/_evaluator.py

@@ -227,7 +227,8 @@ class _Evaluator:
             ctx.update(self.__global_ctx)
             ctx.update(self.__global_ctx)
             # entries in var_val are not always seen (NameError) when passed as locals
             # entries in var_val are not always seen (NameError) when passed as locals
             ctx.update(var_val)
             ctx.update(var_val)
-            expr_evaluated = eval(not_encoded_expr if is_edge_case else expr_string, ctx)
+            with gui._get_autorization():
+                expr_evaluated = eval(not_encoded_expr if is_edge_case else expr_string, ctx)
         except Exception as e:
         except Exception as e:
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             _warn(f"Cannot evaluate expression '{not_encoded_expr if is_edge_case else expr_string}'", e)
             expr_evaluated = None
             expr_evaluated = None

+ 29 - 4
taipy/gui/viselements.json

@@ -23,7 +23,7 @@
           {
           {
             "name": "mode",
             "name": "mode",
             "type": "str",
             "type": "str",
-            "doc": "Define the way the text is processed:<ul><li>&quote;raw&quote;: synonym for setting the *raw* property to True</li><li>&quote;pre&quote;: keeps spaces and new lines</li><li>&quote;markdown&quote; or &quote;md&quote;: basic support for Markdown."
+            "doc": "Define the way the text is processed:<ul><li>&quot;raw&quot;: synonym for setting the *raw* property to True</li><li>&quot;pre&quot;: keeps spaces and new lines</li><li>&quot;markdown&quot; or &quot;md&quot;: basic support for Markdown."
           },
           },
           {
           {
             "name": "format",
             "name": "format",
@@ -219,7 +219,7 @@
           {
           {
             "name": "mode",
             "name": "mode",
             "type": "str",
             "type": "str",
-            "doc": "Define the way the toggle is displayed:<ul><li>&quote;theme&quote;: synonym for setting the *theme* property to True</li></ul>"
+            "doc": "Define the way the toggle is displayed:<ul><li>&quot;theme&quot;: synonym for setting the *theme* property to True</li></ul>"
           }
           }
         ]
         ]
       }
       }
@@ -561,7 +561,7 @@
             "doc": "Allows dynamic config refresh if set to True."
             "doc": "Allows dynamic config refresh if set to True."
           },
           },
           {
           {
-            "name": "figure",
+            "name": "dynamic(figure)",
             "type": "plotly.graph_objects.Figure",
             "type": "plotly.graph_objects.Figure",
             "doc": "A figure as produced by plotly."
             "doc": "A figure as produced by plotly."
           }
           }
@@ -761,6 +761,31 @@
         ]
         ]
       }
       }
     ],
     ],
+    ["login", {
+        "inherits": ["shared"],
+        "properties": [
+            {
+                "name": "title",
+                "default_property": true,
+                "type": "str",
+                "default_value": "Log in",
+                "doc": "The title of the login dialog."
+            },
+            {
+                "name": "on_action",
+                "type": "Callback",
+                "default_value": "on_login",
+                "doc": "The name of the function that is triggered when the dialog's button is pressed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the button.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>args: a list with three elements:<ul><li>The first element is the user name</li><li>The second element is the password</li><li>The third element is the current page name</li></ul></li></li>\n</ul>\n</li>\n</ul>",
+                "signature": [["state", "State"], ["id", "str"], ["payload", "dict"]]
+              },
+              {
+                "name": "message",
+                "type": "dynamic(str)",
+                "doc": "The message shown in the dialog."
+              }
+
+        ]
+    }],
     [
     [
       "menu",
       "menu",
       {
       {
@@ -875,7 +900,7 @@
           {
           {
             "name": "mode",
             "name": "mode",
             "type": "str",
             "type": "str",
-            "doc": "Define the way the selector is displayed:<ul><li>&quote;radio&quote;: list of radio buttons</li><li>&quote;check&quote;: list of check buttons</li><li>any other value: selector as usual."
+            "doc": "Define the way the selector is displayed:<ul><li>&quot;radio&quot;: list of radio buttons</li><li>&quot;check&quot;: list of check buttons</li><li>any other value: selector as usual."
           }
           }
         ]
         ]
       }
       }

+ 38 - 34
taipy/gui_core/_context.py

@@ -104,24 +104,27 @@ class _GuiCoreContext(CoreEventConsumerBase):
 
 
     def process_event(self, event: Event):
     def process_event(self, event: Event):
         if event.entity_type == EventEntityType.SCENARIO:
         if event.entity_type == EventEntityType.SCENARIO:
-            self.scenario_refresh(
-                event.entity_id
-                if event.operation != EventOperation.DELETION and is_readable(t.cast(ScenarioId, event.entity_id))
-                else None
-            )
+            with self.gui._get_autorization(system=True):
+                self.scenario_refresh(
+                    event.entity_id
+                    if event.operation != EventOperation.DELETION and is_readable(t.cast(ScenarioId, event.entity_id))
+                    else None
+                )
         elif event.entity_type == EventEntityType.SEQUENCE and event.entity_id:
         elif event.entity_type == EventEntityType.SEQUENCE and event.entity_id:
             sequence = None
             sequence = None
             try:
             try:
-                sequence = (
-                    core_get(event.entity_id)
-                    if event.operation != EventOperation.DELETION and is_readable(t.cast(SequenceId, event.entity_id))
-                    else None
-                )
-                if sequence and hasattr(sequence, "parent_ids") and sequence.parent_ids:  # type: ignore
-                    self.gui._broadcast(
-                        _GuiCoreContext._CORE_CHANGED_NAME,
-                        {"scenario": [x for x in sequence.parent_ids]},  # type: ignore
+                with self.gui._get_autorization(system=True):
+                    sequence = (
+                        core_get(event.entity_id)
+                        if event.operation != EventOperation.DELETION
+                        and is_readable(t.cast(SequenceId, event.entity_id))
+                        else None
                     )
                     )
+                    if sequence and hasattr(sequence, "parent_ids") and sequence.parent_ids:  # type: ignore
+                        self.gui._broadcast(
+                            _GuiCoreContext._CORE_CHANGED_NAME,
+                            {"scenario": [x for x in sequence.parent_ids]},  # type: ignore
+                        )
             except Exception as e:
             except Exception as e:
                 _warn(f"Access to sequence {event.entity_id} failed", e)
                 _warn(f"Access to sequence {event.entity_id} failed", e)
         elif event.entity_type == EventEntityType.JOB:
         elif event.entity_type == EventEntityType.JOB:
@@ -163,27 +166,28 @@ class _GuiCoreContext(CoreEventConsumerBase):
             client_id = submission.properties.get("client_id")
             client_id = submission.properties.get("client_id")
             if client_id:
             if client_id:
                 running_tasks = {}
                 running_tasks = {}
-                for job in submission.jobs:
-                    job = job if isinstance(job, Job) else core_get(job)
-                    running_tasks[job.task.id] = (
-                        SubmissionStatus.RUNNING.value
-                        if job.is_running()
-                        else SubmissionStatus.PENDING.value
-                        if job.is_pending()
-                        else None
-                    )
-                self.gui._broadcast(_GuiCoreContext._CORE_CHANGED_NAME, {"tasks": running_tasks}, client_id)
-
-                if last_status != new_status:
-                    # callback
-                    submission_name = submission.properties.get("on_submission")
-                    if submission_name:
-                        self.gui._call_user_callback(
-                            client_id,
-                            submission_name,
-                            [core_get(submission.entity_id), {"submission_status": new_status.name}],
-                            submission.properties.get("module_context"),
+                with self.gui._get_autorization(client_id):
+                    for job in submission.jobs:
+                        job = job if isinstance(job, Job) else core_get(job)
+                        running_tasks[job.task.id] = (
+                            SubmissionStatus.RUNNING.value
+                            if job.is_running()
+                            else SubmissionStatus.PENDING.value
+                            if job.is_pending()
+                            else None
                         )
                         )
+                    self.gui._broadcast(_GuiCoreContext._CORE_CHANGED_NAME, {"tasks": running_tasks}, client_id)
+
+                    if last_status != new_status:
+                        # callback
+                        submission_name = submission.properties.get("on_submission")
+                        if submission_name:
+                            self.gui._call_user_callback(
+                                client_id,
+                                submission_name,
+                                [core_get(submission.entity_id), {"submission_status": new_status.name}],
+                                submission.properties.get("module_context"),
+                            )
 
 
             with self.submissions_lock:
             with self.submissions_lock:
                 if new_status in (
                 if new_status in (

+ 24 - 0
tests/gui/builder/control/test_login.py

@@ -0,0 +1,24 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import taipy.gui.builder as tgb
+from taipy.gui import Gui
+
+
+def test_login_builder(gui: Gui, test_client, helpers):
+    with tgb.Page(frame=None) as page:
+        tgb.login(on_action="on_login_action")  # type: ignore[attr-defined]
+    expected_list = [
+        "<Login",
+        'libClassName="taipy-login"',
+        'onAction="on_login_action"',
+    ]
+    helpers.test_control_builder(gui, page, expected_list)

+ 32 - 0
tests/gui/control/test_login.py

@@ -0,0 +1,32 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+from taipy.gui import Gui
+
+
+def test_login_md(gui: Gui, test_client, helpers):
+    md_string = "<|login|on_action=on_login_action|>"
+    expected_list = [
+        "<Login",
+        'libClassName="taipy-login"',
+        'onAction="on_login_action"',
+    ]
+    helpers.test_control_md(gui, md_string, expected_list)
+
+
+def test_login_html(gui: Gui, test_client, helpers):
+    html_string = '<taipy:login on_action="on_login_action" />'
+    expected_list = [
+        "<Login",
+        'libClassName="taipy-login"',
+        'onAction="on_login_action"',
+    ]
+    helpers.test_control_html(gui, html_string, expected_list)

+ 4 - 1
tests/gui_core/test_context_is_readable.py

@@ -9,6 +9,7 @@
 # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 # specific language governing permissions and limitations under the License.
 # specific language governing permissions and limitations under the License.
 
 
+import contextlib
 import typing as t
 import typing as t
 from unittest.mock import Mock, patch
 from unittest.mock import Mock, patch
 
 
@@ -150,7 +151,9 @@ class TestGuiCoreContext_is_readable:
     def test_submission_status_callback(self):
     def test_submission_status_callback(self):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get) as mockget:
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get) as mockget:
             mockget.reset_mock()
             mockget.reset_mock()
-            gui_core_context = _GuiCoreContext(Mock())
+            mockGui = Mock(Gui)
+            mockGui._get_autorization = lambda s: contextlib.nullcontext()
+            gui_core_context = _GuiCoreContext(mockGui)
 
 
             def sub_cb():
             def sub_cb():
                 return True
                 return True