diff --git a/frappe/auth.py b/frappe/auth.py index 5a447a99af..1658930317 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -253,7 +253,11 @@ class LoginManager: ): return - clear_sessions(frappe.session.user, keep_current=True) + clear_sessions( + frappe.session.user, + keep_current=True, + force=frappe.session.user != "Administrator", + ) def authenticate(self, user: str | None = None, pwd: str | None = None): from frappe.core.doctype.user.user import User diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index c1aa3fb0d7..75d1bd978a 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -248,7 +248,6 @@ }, { "default": "0", - "description": "Note: Multiple sessions will be allowed in case of mobile device", "fieldname": "deny_multiple_sessions", "fieldtype": "Check", "label": "Allow only one session per user" @@ -790,7 +789,7 @@ "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2026-01-02 18:13:45.430712", + "modified": "2026-02-24 14:27:04.763075", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 29d9cb6b0f..1424e2a3d8 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -76,6 +76,18 @@ class Workspace(Document): if self.public and not is_workspace_manager() and not disable_saving_as_public(): frappe.throw(_("You need to be Workspace Manager to edit this document")) + + if ( + not self.public + and self.for_user + and self.for_user != frappe.session.user + and not is_workspace_manager() + ): + frappe.throw( + _("You are not allowed to edit this workspace"), + frappe.PermissionError, + ) + if self.has_value_changed("title"): validate_route_conflict(self.doctype, self.title) else: diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index b0576dfe38..ca05ec6278 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -514,7 +514,18 @@ def send_now(name: str | int, force_send: bool = False): @frappe.whitelist() def toggle_sending(enable: bool | int | str): frappe.only_for("System Manager") - frappe.db.set_default("suspend_email_queue", 0 if sbool(enable) else 1) + suspend_value = 0 if sbool(enable) else 1 + frappe.db.set_default("suspend_email_queue", suspend_value) + + action = "Resumed" if suspend_value == 0 else "Suspended" + frappe.get_doc( + { + "doctype": "Activity Log", + "user": frappe.session.user, + "status": "Success", + "subject": f"Email Queue sending {action.lower()}", + } + ).insert(ignore_permissions=True, ignore_links=True) def on_doctype_update(): diff --git a/frappe/oauth.py b/frappe/oauth.py index 5e170db5d2..67595555f9 100644 --- a/frappe/oauth.py +++ b/frappe/oauth.py @@ -2,13 +2,14 @@ import base64 import datetime import hashlib import re -from http import cookies -from urllib.parse import unquote, urljoin, urlparse +from urllib.parse import urljoin, urlparse +from oauthlib.common import Request from oauthlib.openid import RequestValidator import frappe from frappe.auth import LoginManager +from frappe.integrations.doctype.oauth_client.oauth_client import OAuthClient from frappe.utils.data import cstr, get_system_timezone, now_datetime @@ -73,13 +74,11 @@ class OAuthWebRequestValidator(RequestValidator): # Post-authorization def save_authorization_code(self, client_id, code, request, *args, **kwargs): - cookie_dict = get_cookie_dict_from_headers(request) - oac = frappe.new_doc("OAuth Authorization Code") oac.scopes = get_url_delimiter().join(request.scopes) oac.redirect_uri_bound_to_authorization_code = request.redirect_uri oac.client = client_id - oac.user = unquote(cookie_dict["user_id"].value) + oac.user = frappe.session.user oac.authorization_code = code["code"] if request.nonce: @@ -92,43 +91,32 @@ class OAuthWebRequestValidator(RequestValidator): oac.save(ignore_permissions=True) frappe.db.commit() - def authenticate_client(self, request, *args, **kwargs): + def authenticate_client(self, request: Request, *args, **kwargs) -> bool | None: + """ + Loads the client based on request parameters and sets in oauth request. + Returns True on success, None on error. + """ # Get ClientID in URL if request.client_id: - oc = frappe.get_doc("OAuth Client", request.client_id) + client_name = request.client_id else: # Extract token, instantiate OAuth Bearer Token and use clientid from there. if "refresh_token" in frappe.form_dict: - oc = frappe.get_doc( - "OAuth Client", - frappe.db.get_value( - "OAuth Bearer Token", - {"refresh_token": frappe.form_dict["refresh_token"]}, - "client", - ), - ) + token_filters = {"refresh_token": frappe.form_dict["refresh_token"]} elif "token" in frappe.form_dict: - oc = frappe.get_doc( - "OAuth Client", - frappe.db.get_value("OAuth Bearer Token", frappe.form_dict["token"], "client"), - ) + token_filters = {"name": frappe.form_dict["token"]} else: - oc = frappe.get_doc( - "OAuth Client", - frappe.db.get_value( - "OAuth Bearer Token", - frappe.get_request_header("Authorization").split(" ")[1], - "client", - ), - ) + token_filters = {"name": frappe.get_request_header("Authorization").split(" ")[1]} + + client_name = frappe.db.get_value("OAuth Bearer Token", filters=token_filters, fieldname="client") + + oc: OAuthClient = frappe.get_doc("OAuth Client", client_name) try: request.client = request.client or oc.as_dict() except Exception as e: return generate_json_error_response(e) - cookie_dict = get_cookie_dict_from_headers(request) - user_id = unquote(cookie_dict.get("user_id").value) if "user_id" in cookie_dict else "Guest" - return frappe.session.user == user_id + return True def authenticate_client_id(self, client_id, request, *args, **kwargs): cli_id = frappe.db.get_value("OAuth Client", client_id, "name") @@ -506,13 +494,6 @@ class OAuthWebRequestValidator(RequestValidator): return True -def get_cookie_dict_from_headers(r): - cookie = cookies.BaseCookie() - if r.headers.get("Cookie"): - cookie.load(r.headers.get("Cookie")) - return cookie - - def calculate_at_hash(access_token, hash_alg): """Helper method for calculating an access token hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken diff --git a/frappe/printing/doctype/letter_head/letter_head.json b/frappe/printing/doctype/letter_head/letter_head.json index 9855c0ff32..4ddafc47db 100644 --- a/frappe/printing/doctype/letter_head/letter_head.json +++ b/frappe/printing/doctype/letter_head/letter_head.json @@ -98,6 +98,7 @@ "description": "Letter Head in HTML", "fieldname": "content", "fieldtype": "HTML Editor", + "ignore_xss_filter": 1, "label": "Header HTML", "oldfieldname": "content", "oldfieldtype": "Text Editor" @@ -113,6 +114,7 @@ "description": "Footer will display correctly only in PDF", "fieldname": "footer", "fieldtype": "HTML Editor", + "ignore_xss_filter": 1, "label": "Footer HTML" }, { @@ -184,6 +186,7 @@ { "collapsible": 1, "collapsible_depends_on": "eval: doc.header_script || doc.footer_script", + "depends_on": "eval: !doc.__islocal", "fieldname": "scripts_section", "fieldtype": "Section Break", "label": "Scripts" @@ -200,7 +203,7 @@ "links": [], "make_attachments_public": 1, "max_attachments": 3, - "modified": "2024-04-12 10:30:25.793932", + "modified": "2026-02-24 20:53:14.297567", "modified_by": "Administrator", "module": "Printing", "name": "Letter Head", @@ -223,8 +226,9 @@ "role": "Desk User" } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index f2f69e3aa3..0e5d68c165 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -706,7 +706,10 @@ frappe.ui.form.PrintView = class { return; } } else { - this.is_wkhtmltopdf_valid(); + let pdf_generator = this.get_pdf_generator(print_format?.pdf_generator); + if (pdf_generator === "wkhtmltopdf") { + this.is_wkhtmltopdf_valid(); + } this.render_page( "/api/method/frappe.utils.print_format.download_pdf?", false, @@ -738,9 +741,7 @@ frappe.ui.form.PrintView = class { encodeURIComponent(this.get_letterhead()) + "&settings=" + encodeURIComponent(JSON.stringify(this.additional_settings)) + - (this.lang_code ? "&_lang=" + this.lang_code : "") + - "&pdf_generator=" + - encodeURIComponent(pdf_generator) + (this.lang_code ? "&_lang=" + this.lang_code : "") ) ); if (!w) { diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index d9c6514556..2c28a24063 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -291,8 +291,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { set_primary_action() { if (this.can_create && !frappe.boot.read_only) { const doctype_name = __(frappe.router.doctype_layout) || __(this.doctype); + const add_button_label = __("Add {0}", [doctype_name], "Primary action in list view"); const create_button = this.page.set_primary_action( - __("Add {0}", [doctype_name], "Primary action in list view"), + add_button_label, () => { if (this.settings.primary_action) { this.settings.primary_action(); @@ -304,12 +305,26 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { ); if (frappe.is_mobile()) { create_button.append(__("Add")); + } else { + this._trim_primary_action_if_overflow(create_button, add_button_label); } } else { this.page.clear_primary_action(); } } + _trim_primary_action_if_overflow(btn, add_button_label) { + const container = this.page.wrapper.find(".page-head-content")[0]; + if (!container || !btn[0]) return; + const containerRect = container.getBoundingClientRect(); + const btnRect = btn[0].getBoundingClientRect(); + if (btnRect.right > containerRect.right) { + const short_label = __("Add"); + btn.attr("title", add_button_label).tooltip(); + btn.find("span").text(short_label); + } + } + make_new_doc() { const doctype = this.doctype; const options = {}; diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.html b/frappe/public/js/frappe/ui/sidebar/sidebar.html index ef5e6d630a..72498d5327 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.html +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.html @@ -43,7 +43,7 @@

{%= frappe.utils.icon("user-check" , "sm", "", "", "text-ink-gray-7 current-color", true)%} - {%= __("Getting started") %} + {%= __("Getting Started") %}

diff --git a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue index 68150e9744..17dde86cc6 100644 --- a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue +++ b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue @@ -170,6 +170,7 @@ function updateSettings(step) { }; frappe.set_route("Form", step.reference_document); + markComplete(step); } async function createEntry(step) { @@ -263,10 +264,10 @@ function markReset(step) {
- {{ __("Reset all") }} + {{ __("Reset All") }}
- Skip all + {{ __("Skip All") }}
diff --git a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js index 86a994519e..c75170d28b 100644 --- a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js +++ b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js @@ -215,6 +215,46 @@ function addStyles() { color: #6b7280; font-size: 14px; } + + [data-theme="dark"] .onb-panel { + background-color: #232323; + color: #e5e7eb; + box-shadow: 0 12px 40px rgba(0,0,0,0.6); + } + + [data-theme="dark"] .text-base { + color: #e5e7eb; + } + + [data-theme="dark"] .onb-skip { + color: #9ca3af; + } + + [data-theme="dark"] .onb-skip:hover { + color: #f3f4f6; + } + + [data-theme="dark"] .onb-title-steps, + [data-theme="dark"] .onb-progress-text { + color: #9ca3af; + } + + [data-theme="dark"] .onb-group:hover { + background: #374151; + color: #f3f4f6; + } + + [data-theme="dark"] .onb-progress-badge { + background: rgba(245,158,11,0.15); + color: #fbbf24; + } + + [data-theme="dark"] .onb-progress-badge-complete { + background: rgba(16,185,129,0.15); + color: #34d399; + } + + `; document.head.appendChild(style); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index bfc1d0ad38..fb888880f3 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1575,7 +1575,8 @@ Object.assign(frappe.utils, { if (item.is_query_report) { route = "query-report/" + item.name; } else if (!item.is_query_report && item.report_ref_doctype) { - route = frappe.router.slug(item.report_ref_doctype) + "/view/report/"; + route = + frappe.router.slug(item.report_ref_doctype) + "/view/report/" + item.name; } else { route = "report/" + item.name; } @@ -1909,7 +1910,13 @@ Object.assign(frappe.utils, { process_filter_expression(filter) { let filters = []; - filters = filter ? new Function(`return ${filter}`)() : []; + if (filter) { + try { + filters = JSON.parse(filter); + } catch { + console.warn("Invalid JSON in filter expression", filter); + } + } return this.cleanup_filters(filters); }, cleanup_filters(filters) { diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 704dca77d1..9b980f5d51 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -2,6 +2,7 @@ import os from urllib.parse import urlparse import frappe +from frappe.website.page_renderers.document_page import _find_matching_document_webview from frappe.website.page_renderers.template_page import TemplatePage from frappe.website.utils import can_cache @@ -26,10 +27,26 @@ class NotFoundPage(TemplatePage): def can_cache_404(self): # do not cache 404 for custom homepages - return can_cache() and self.request_url and not self.is_custom_home_page() + # also skip caching docs with website permission checks (access is dynamic) + return ( + can_cache() + and self.request_url + and not self.is_custom_home_page() + and not self.has_website_permission_check() + ) def is_custom_home_page(self): url_parts = urlparse(self.request_url) request_url = os.path.splitext(url_parts.path)[0] request_path = os.path.splitext(self.request_path)[0] return request_url in HOMEPAGE_PATHS and request_path not in HOMEPAGE_PATHS + + def has_website_permission_check(self): + request_path = os.path.splitext(self.request_path)[0] + if not (document := _find_matching_document_webview(request_path)): + return False + doctype, docname = document + doc = frappe.get_cached_doc(doctype, docname) + return hasattr(doc, "has_website_permission") or bool( + frappe.get_hooks("has_website_permission", {}).get(doctype) + ) diff --git a/frappe/www/printview.html b/frappe/www/printview.html index c3c557c8e4..17e6380baa 100644 --- a/frappe/www/printview.html +++ b/frappe/www/printview.html @@ -18,7 +18,7 @@ {{ _("Print") }}
+ href="/api/method/frappe.utils.print_format.download_pdf?doctype={{doctype|e}}&name={{name|e}}&format={{print_format|e}}&letterhead={{letterhead|e}}&no_letterhead={{no_letterhead|e}}&_lang={{lang|e}}&key={{key|e}}&pdf_generator={{pdf_generator|e}}"> {{ _('Get PDF') }} diff --git a/frappe/www/printview.py b/frappe/www/printview.py index b6b7d9aa8d..31d47e6d73 100644 --- a/frappe/www/printview.py +++ b/frappe/www/printview.py @@ -92,6 +92,7 @@ def get_context(context) -> PrintContext: # Include selected print format name in access log print_format_name = getattr(print_format, "name", "Standard") + pdf_generator = getattr(print_format, "pdf_generator", "wkhtmltopdf") make_access_log( doctype=frappe.form_dict.doctype, @@ -114,7 +115,7 @@ def get_context(context) -> PrintContext: "print_format": print_format_name, "letterhead": letterhead, "no_letterhead": frappe.form_dict.no_letterhead, - "pdf_generator": frappe.form_dict.get("pdf_generator", "wkhtmltopdf"), + "pdf_generator": frappe.form_dict.get("pdf_generator", pdf_generator), } diff --git a/yarn.lock b/yarn.lock index 9dd12ad0e0..689fa38fba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2055,9 +2055,9 @@ mime@^1.4.1: integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + version "3.1.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624" + integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA== dependencies: brace-expansion "^1.1.7"