diff --git a/apps/_scaffold/static/css/no.css b/apps/_scaffold/static/css/no.css index a2c837e8d..32e5b52ed 100644 --- a/apps/_scaffold/static/css/no.css +++ b/apps/_scaffold/static/css/no.css @@ -540,3 +540,52 @@ ul.tags-list li { ul.tags-list li[data-selected=true] { opacity: 1.0; } + + +flash-alerts { + position: fixed; + top: 20px; + right: 20px; + z-index: 1050; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; + /* Evita que los clics pasen a través del contenedor si está vacío */ + pointer-events: none; +} +/* Permite hacer clic en los toasts hijos */ +flash-alerts > * { + pointer-events: auto; +} + +/* 2. Lógica de animación genérica. + Esto funcionará para CUALQUIER elemento que coloques dentro de flash-alerts. */ + +/* Estado inicial (oculto) de cualquier notificación */ +flash-alerts > * { + opacity: 0; + transform: translateX(100%); + /* Define la transición que se aplicará cuando cambien las propiedades */ + transition: all 0.4s ease-in-out; +} + +/* Estado final (visible) cuando se añade la clase .toast-show */ +flash-alerts > *.toast-show { + opacity: 1; + transform: translateX(0); +} + +/* 3. (Opcional) Estilo para el botón de cierre genérico, por si no usas Bulma. */ +.toast-close-btn { + float: right; + cursor: pointer; + font-size: 1.5rem; + font-weight: bold; + line-height: 1; + margin-left: 15px; + opacity: 0.5; +} +.toast-close-btn:hover { + opacity: 1; +} \ No newline at end of file diff --git a/apps/_scaffold/static/js/utils.js b/apps/_scaffold/static/js/utils.js index 03545a534..5efdb7132 100644 --- a/apps/_scaffold/static/js/utils.js +++ b/apps/_scaffold/static/js/utils.js @@ -534,69 +534,90 @@ Q.flash = function (detail) { // Displays flash messages Q.handle_flash = function () { const elem = Q("flash-alerts")[0]; - /** @type {(arg0: HTMLElement) => (event: CustomEvent) => void} */ - let make_handler; - if ("bootstrap" in window) { - make_handler = function (elem) { - return function (event) { - let node = document.createElement("div"); - const color = event.detail.class || "info"; - node.innerHTML = ``; - // @ts-ignore - node = node.firstElementChild; - elem.appendChild(node); - bootstrap.Toast.getOrCreateInstance(node).show(); - node.addEventListener("hidden.bs.toast", () => { - node.parentNode.removeChild(node); - }); - }; - }; - } else { - const make_delete_handler = function (node) { - return function () { - node.parentNode.removeChild(node); - }; - }; - make_handler = function (elem) { - return function (event) { - let node = document.createElement("div"); - node.innerHTML = `
${event.detail.message}
`; - // @ts-ignore - node = Q('[role="alert"]', node)[0]; - node.classList.add(event.detail.class || "info"); - elem.appendChild(node); - Q('[role="alert"] .close', node)[0].onclick = - make_delete_handler(node); - }; - }; + // Si no hay un elemento en la página, no hacemos nada. + if (!elem) { + return; } - if (elem) { - elem.addEventListener("flash", make_handler(elem), false); - /** - * - * @param {FlashDetails} detail - */ - Q.flash = function (detail) { - elem.dispatchEvent(new CustomEvent("flash", { detail: detail })); + const make_custom_toast_handler = function (container) { + return function (event) { + const detail = event.detail; + + // Validar que tengamos los datos mínimos + if (!detail.message) { + detail.message = "" + } + if ( !detail.class) { + detail.class = "solid-flash" + } + + // 1. Crear el elemento principal y aplicar TODAS las clases del backend. + const toast = document.createElement("div"); + toast.className = detail.class; // Clave: Asigna el string completo de clases. + + // 2. Crear el botón de cierre basado en una convención (Bulma o genérico) + let closeButton; + if (detail.class.includes('notification')) { + // Si es una notificación de Bulma, usa su estructura. + closeButton = document.createElement('button'); + closeButton.className = 'delete'; + } else { + // Fallback para un sistema genérico o personalizado. + closeButton = document.createElement('span'); + closeButton.className = 'toast-close-btn'; // Una clase genérica para darle estilo + closeButton.innerHTML = '×'; // El símbolo 'x' + } + + // 3. Ensamblar el toast + toast.appendChild(closeButton); + // Añadir el mensaje como texto para evitar problemas de XSS. + // El espacio inicial es para separarlo del botón. + toast.appendChild(document.createTextNode(" " + detail.message)); + + // 4. Añadir el toast al contenedor + container.appendChild(toast); + + // 5. Lógica para cerrar y eliminar el toast + const closeToast = () => { + // Quitamos la clase de estado para iniciar la animación de salida + toast.classList.remove('toast-show'); + + // Cuando la transición termine, eliminamos el elemento del DOM + toast.addEventListener('transitionend', () => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, { once: true }); + }; + + // 6. Asignar eventos + closeButton.onclick = closeToast; + setTimeout(closeToast, 5000); // Auto-cierre + + // 7. Iniciar la animación de entrada añadiendo la clase de estado + setTimeout(() => { + toast.classList.add('toast-show'); + }, 10); }; - if (elem.dataset.alert) Q.flash(Q.eval(elem.dataset.alert)); + }; + + elem.addEventListener("flash", make_custom_toast_handler(elem), false); + + Q.flash = function (detail) { + elem.dispatchEvent(new CustomEvent("flash", { detail: detail })); + }; + + if (elem.dataset.alert) { + try { + const alertDataStr = elem.dataset.alert.replace(/'/g, '"'); + const initialAlert = JSON.parse(alertDataStr); + if (initialAlert && initialAlert.message) { + Q.flash(initialAlert); + } + } catch (e) { + console.error("Failed to parse initial flash data:", elem.dataset.alert, e); + } } }; diff --git a/docs/chapter-06.rst b/docs/chapter-06.rst index e60f3bc49..9691d9794 100644 --- a/docs/chapter-06.rst +++ b/docs/chapter-06.rst @@ -338,15 +338,88 @@ The Flash helper handles the server side of them. Here is an example: @action('index') @action.uses(flash) def index(): - flash.set("Hello World", _class="info", sanitize=True) + flash.set("Hello World", _class="solid-flash", sanitize=True) return dict() -and in the template: + +Example with Bulma CSS classes for notifications. + +.. code:: python + @action('index2') + @action.uses(flash) + def index(): + flash.set("Hello World", _class="notification is-success", sanitize=True) + return dict() + + +To make the flash messages smooth and visually appealing, we need some CSS. This piece of CSS is included in ``no.css`` when using the default template. +.. code:: html + + flash-alerts { + position: fixed; + top: 20px; + right: 20px; + z-index: 1050; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; + /* Prevent clicks from passing through the container if it is empty */ + pointer-events: none; + } + /* Allows clicking on child toasts */ + flash-alerts > * { + pointer-events: auto; + } + + /* 2. Generic animation logic. + This will work for ANY element you place inside flash-alerts. */ + + /* Initial state (hidden) of any notification */ + flash-alerts > * { + opacity: 0; + transform: translateX(100%); + /* Defines the transition that will be applied when properties change */ + transition: all 0.4s ease-in-out; + } + + /* Final state (visible) when adding the .toast-show class */ + flash-alerts > *.toast-show { + opacity: 1; + transform: translateX(0); + } + + /* 3. (Optional) Style for the generic close button, in case you don't use Bulma. */ + .toast-close-btn { + float: right; + cursor: pointer; + font-size: 1.5rem; + font-weight: bold; + line-height: 1; + margin-left: 15px; + opacity: 0.5; + } + .toast-close-btn:hover { + opacity: 1; + + } + //Default backround + .solid-flash { + background-color: #d1d1d1; /* Light gray */ + padding: 5px; + padding-left: 10px; + border-radius: 10px; + } + + +And in the template: .. code:: html + + By setting the value of the message in the flash helper, a flash variable is returned by the action and this triggers the JS in the template to inject the message in the ``py4web-flash`` DIV which you @@ -363,14 +436,17 @@ The client can also set/add flash messages by calling: :: - Q.flash({'message': 'hello world', 'class': 'info'}); + Q.flash({'message': 'hello world', 'class': 'solid-flash'}); + + +Remember to include ``utils.js`` in your ``layout.html``. This file is located in every new app in /static/js/utils.js, contains the logic to properly show and manage flash messages. + +Py4web defaults to an alert class called ``solid-flash`` which is the default. Yet, there is nothing in py4web +that hardcodes those names. You can use your own class names, or use any class you have available. + + -py4web defaults to an alert class called ``info`` and most CSS -frameworks define classes for alerts called ``success``, ``error``, -``warning``, ``default``, and ``info``. Yet, there is nothing in py4web -that hardcodes those names. You can use your own class names. -You can see the basic usage of flash messages in the **examples** app. The Session fixture ------------------- diff --git a/docs/chapter-13.rst b/docs/chapter-13.rst index 4b49a0431..14de099ff 100644 --- a/docs/chapter-13.rst +++ b/docs/chapter-13.rst @@ -199,6 +199,115 @@ Example: auth.fix_actions() +Customize Auth messages +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Every form and action message has a standard text for labels, buttons, and emails; however, these can be customized by overriding the values. + +For example in ``settings.py`` you can add: + +.. code:: python + MESSAGES = { + + "verify_email": { + "subject": "Please confirm your email address", + "body": "Hi {first_name},\n\nThank you for joining us! To activate your account, simply click the link below:\n\n{link}\n\nIf you didn’t create an account, please ignore this email.\n\nBest regards,\nThe Team" + }, + + "reset_password": { + "subject": "Reset your password", + "body": "Hello {first_name},\n\nWe received a request to reset your password. Click the link below to choose a new one:\n\n{link}\n\nIf you didn’t request this, please ignore the email or contact support.\n\nThanks,\nSupport Team" + }, + + "unsubscribe": { + "subject": "You’re unsubscribed", + "body": "Hi {first_name},\n\nWe’re sorry to see you go. You’ve been removed from our mailing list and will no longer receive emails.\n\nIf you change your mind, feel free to re‑subscribe anytime by visiting our website.\n\nThank you for having been with us.\n\nBest,\nThe Team" + }, + "flash": { + "user-registered": "User registered", + "password-reset-link-sent": "Password reset link sent", + "password-changed": "Password changed", + "profile-saved": "Profile saved", + "user-logout": "User logout", + "email-verified": "Email verified", + "link-expired": "Link invalid or expired", + "login-required": "Login required", + }, + "labels": { + "username": "Username", + "email": "Email", + "first_name": "First Name", + "last_name": "Last Name", + "phone_number": "Phone Number", + "username_or_email": "Username or Email", + "password": "Password", + "new_password": "New Password", + "old_password": "Old Password", + "login_password": "Password", + "password_again": "Password (again)", + "created_on": "Created On", + "created_by": "Created By", + "modified on": "Modified On", + "modified by": "Modified By", + "two_factor": "Authentication Code", + }, + "buttons": { + "lost-password": "Lost Password", + "register": "Register", + "request": "Request", + "sign-in": "Sign In", + "sign-up": "Sign Up", + "submit": "Submit", + }, + "errors": { + "registration_is_pending": "Registration is pending", + "account_is_blocked": "Account is blocked", + "account_needs_to_be_approved": "Account needs to be approved", + "invalid_credentials": "Invalid Credentials", + "invalid_token": "invalid token", + "password_doesnt_match": "Password doesn't match", + "invalid_current_password": "invalid current password", + "new_password_is_the_same_as_previous_password": "new password is the same as previous password", + "new_password_was_already_used": "new password was already used", + "invalid": "invalid", + "no_post_payload": "no post payload", + "two_factor": "Verification code does not match", + "two_factor_max_tries": "Two factor max tries exceeded", + }, + } + + BUTTON_CLASSES = { + "lost-password": "white", + "register": "white", + "request": "white", + "sign-in": "white", + "sign-up": "white", + "submit": "white", + } + + +In ``common.py`` in Auth section you need to add: **auth.param.messages = settings.AUTH_MESSAGES** + +.. code:: python + auth.param.registration_requires_confirmation = settings.VERIFY_EMAIL + auth.param.registration_requires_approval = settings.REQUIRES_APPROVAL + auth.param.login_after_registration = settings.LOGIN_AFTER_REGISTRATION + auth.param.allowed_actions = settings.ALLOWED_ACTIONS + auth.param.login_expiration_time = 3600 + auth.param.password_complexity = {"entropy": settings.PASSWORD_ENTROPY} + auth.param.block_previous_password_num = 3 + auth.param.default_login_enabled = settings.DEFAULT_LOGIN_ENABLED + + #Auth messages + auth.param.messages = settings.AUTH_MESSAGES + + auth.define_tables() + auth.fix_actions() + auth.logger = logger + + flash = auth.flash + + Authentication with CAPTCHA ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -264,7 +373,7 @@ Finally in ``auth.html`` add: After completing these steps, the reCAPTCHA field will be added to the login, register, and request_reset_password forms. -Enabling hCAPTCHA +Enabling HCAPTCHA ^^^^^^^^^^^^^^^^^ in ``settings.py`` add your HCAPTCHA_SITE_KEY and HCAPTCHA_SECRET_KEY: diff --git a/py4web/utils/hcaptcha.py b/py4web/utils/hcaptcha.py index 293cafabe..dd410c2f4 100644 --- a/py4web/utils/hcaptcha.py +++ b/py4web/utils/hcaptcha.py @@ -70,18 +70,14 @@ def field(self): return Field("h_captcha_response", "hidden", requires=self.validator) def validator(self, value, _): - print(value) - print(self.secret_key) # Build payload with secret key and token. data = {"secret": self.secret_key, "response": value} # Make POST request with data payload to hCaptcha API endpoint. res = requests.post(url="https://hcaptcha.com/siteverify", data=data) - print(res.text) try: if res.json()["success"]: return (True, None) return (False, "Invalid Hcaptcha value") except Exception as exc: - print(exc) return (False, str(exc))