diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index f9d7adceef..ddafd0e9fd 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -39,6 +39,8 @@
"allow_login_using_mobile_number",
"allow_login_using_user_name",
"disable_user_pass_login",
+ "login_with_email_link",
+ "login_with_email_link_expiry",
"allow_error_traceback",
"strip_exif_metadata_from_uploaded_images",
"allow_older_web_view_links",
@@ -416,11 +418,11 @@
"label": "Send document Web View link in email"
},
{
- "collapsible": 1,
- "fieldname": "prepared_report_section",
- "fieldtype": "Section Break",
- "label": "Reports"
- },
+ "collapsible": 1,
+ "fieldname": "prepared_report_section",
+ "fieldtype": "Section Break",
+ "label": "Reports"
+ },
{
"default": "Frappe",
"description": "The application name will be used in the Login page.",
@@ -504,12 +506,26 @@
"fieldname": "disable_user_pass_login",
"fieldtype": "Check",
"label": "Disable Username/Password Login"
+ },
+ {
+ "default": "0",
+ "description": "Allow users to log in without a password, using a login link sent to their email",
+ "fieldname": "login_with_email_link",
+ "fieldtype": "Check",
+ "label": "Login with email link"
+ },
+ {
+ "default": "10",
+ "depends_on": "login_with_email_link",
+ "fieldname": "login_with_email_link_expiry",
+ "fieldtype": "Int",
+ "label": "Login with email link expiry (in minutes)"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2022-11-28 17:57:05.099512",
+ "modified": "2022-12-20 21:45:37.651668",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
diff --git a/frappe/handler.py b/frappe/handler.py
index 30ebe30c9d..0a25f329c7 100644
--- a/frappe/handler.py
+++ b/frappe/handler.py
@@ -33,6 +33,8 @@ ALLOWED_MIMETYPES = (
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"text/plain",
+ "video/quicktime",
+ "video/mp4",
)
diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js
index 76c99652c6..adb4df9d2a 100644
--- a/frappe/public/js/frappe/list/list_view.js
+++ b/frappe/public/js/frappe/list/list_view.js
@@ -1321,7 +1321,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
if (this.list_view_settings && this.list_view_settings.disable_auto_refresh) {
return;
}
- frappe.socketio.list_subscribe(this.doctype);
+ frappe.socketio.doctype_subscribe(this.doctype);
frappe.realtime.on("list_update", (data) => {
if (!frappe.get_doc(data?.doctype, data?.name)?.__unsaved) {
frappe.model.remove_from_locals(data.doctype, data.name);
diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js
index 1ac875544c..792346ed87 100644
--- a/frappe/public/js/frappe/socketio_client.js
+++ b/frappe/public/js/frappe/socketio_client.js
@@ -129,8 +129,8 @@ frappe.socketio = {
task_unsubscribe: function (task_id) {
frappe.socketio.socket.emit("task_unsubscribe", task_id);
},
- list_subscribe: function (doctype) {
- frappe.socketio.socket.emit("list_update", doctype);
+ doctype_subscribe: function (doctype) {
+ frappe.socketio.socket.emit("doctype_subscribe", doctype);
},
doc_subscribe: function (doctype, docname) {
if (frappe.flags.doc_subscribe) {
diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js
index f58b06f6cf..cbd9bebcb0 100644
--- a/frappe/public/js/frappe/views/reports/report_view.js
+++ b/frappe/public/js/frappe/views/reports/report_view.js
@@ -56,7 +56,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
if (this.list_view_settings?.disable_auto_refresh) {
return;
}
- frappe.socketio.list_subscribe(this.doctype);
+ frappe.socketio.doctype_subscribe(this.doctype);
frappe.realtime.on("list_update", (data) => this.on_update(data));
}
diff --git a/frappe/public/scss/login.bundle.scss b/frappe/public/scss/login.bundle.scss
index 488dd4106e..b491e49a50 100644
--- a/frappe/public/scss/login.bundle.scss
+++ b/frappe/public/scss/login.bundle.scss
@@ -7,6 +7,7 @@ body {
}
.for-forgot,
+.for-login-with-email-link,
.for-signup,
.for-email-login {
display: none;
@@ -14,6 +15,7 @@ body {
.for-login,
.for-forgot,
+.for-login-with-email-link,
.for-signup,
.for-email-login {
padding: max(10vh, 60px) 0;
diff --git a/frappe/realtime.py b/frappe/realtime.py
index eff3ea2b77..6283b8eb80 100644
--- a/frappe/realtime.py
+++ b/frappe/realtime.py
@@ -115,7 +115,7 @@ def can_subscribe_doc(doctype: str, docname: str) -> bool:
@frappe.whitelist(allow_guest=True)
-def can_subscribe_list(doctype: str) -> bool:
+def can_subscribe_doctype(doctype: str) -> bool:
from frappe.exceptions import PermissionError
if not frappe.has_permission(user=frappe.session.user, doctype=doctype, ptype="read"):
diff --git a/frappe/templates/emails/login_with_email_link.html b/frappe/templates/emails/login_with_email_link.html
new file mode 100644
index 0000000000..144869e2e6
--- /dev/null
+++ b/frappe/templates/emails/login_with_email_link.html
@@ -0,0 +1,41 @@
+{% macro table(content, table_class) %}
+
+
+
+ |
+ {{ content }}
+ |
+
+
+
+{% endmacro %}
+
+{% macro body() %}
+
+
+
+ |
+
+ {{ _('The link will expire in {0} minutes').format(minutes) }}
+ |
+
+
+ |
+
+ |
+
+
+
+{% endmacro %}
+
+
+
+ {{ table(table(body(), 'email-body'), 'email-container') }}
+
+
\ No newline at end of file
diff --git a/frappe/templates/includes/login/login.js b/frappe/templates/includes/login/login.js
index defbbc2975..16f78ea0c9 100644
--- a/frappe/templates/includes/login/login.js
+++ b/frappe/templates/includes/login/login.js
@@ -55,6 +55,25 @@ login.bind_events = function () {
return false;
});
+ $(".form-login-with-email-link").on("submit", function (event) {
+ event.preventDefault();
+ var args = {};
+ args.cmd = "frappe.www.login.send_login_link";
+ args.email = ($("#login_with_email_link_email").val() || "").trim();
+ if (!args.email) {
+ login.set_status('{{ _("Valid Login id required.") }}', 'red');
+ return false;
+ }
+ login.call(args).then(() => {
+ login.set_status('{{ _("Login link sent to your email") }}', 'blue');
+ $("#login_with_email_link_email").val("");
+ }).catch(() => {
+ login.set_status('{{ _("Send login link") }}', 'blue');
+ });
+
+ return false;
+ });
+
$(".toggle-password").click(function () {
var input = $($(this).attr("toggle"));
if (input.attr("type") == "password") {
@@ -86,6 +105,7 @@ login.bind_events = function () {
login.route = function () {
var route = window.location.hash.slice(1);
if (!route) route = "login";
+ route = route.replaceAll("-", "_");
login[route]();
}
@@ -94,6 +114,7 @@ login.reset_sections = function (hide) {
$("section.for-login").toggle(false);
$("section.for-email-login").toggle(false);
$("section.for-forgot").toggle(false);
+ $("section.for-login-with-email-link").toggle(false);
$("section.for-signup").toggle(false);
}
$('section:not(.signup-disabled) .indicator').each(function () {
@@ -121,10 +142,22 @@ login.steptwo = function () {
login.forgot = function () {
login.reset_sections();
+ if ($("#login_email").val()) {
+ $("#forgot_email").val($("#login_email").val());
+ }
$(".for-forgot").toggle(true);
$("#forgot_email").focus();
}
+login.login_with_email_link = function () {
+ login.reset_sections();
+ if ($("#login_email").val()) {
+ $("#login_with_email_link_email").val($("#login_email").val());
+ }
+ $(".for-login-with-email-link").toggle(true);
+ $("#login_with_email_link_email").focus();
+}
+
login.signup = function () {
login.reset_sections();
$(".for-signup").toggle(true);
@@ -270,7 +303,7 @@ frappe.ready(function () {
$(window).trigger("hashchange");
}
- $(".form-signup, .form-forgot").removeClass("hide");
+ $(".form-signup, .form-forgot, .form-login-with-email-link").removeClass("hide");
$(document).trigger('login_rendered');
});
diff --git a/frappe/tests/test_auth.py b/frappe/tests/test_auth.py
index 403d0cf34c..fe8a290a04 100644
--- a/frappe/tests/test_auth.py
+++ b/frappe/tests/test_auth.py
@@ -2,11 +2,14 @@
# License: MIT. See LICENSE
import time
+import requests
+
import frappe
import frappe.utils
from frappe.auth import LoginAttemptTracker
from frappe.frappeclient import AuthError, FrappeClient
from frappe.tests.utils import FrappeTestCase
+from frappe.www.login import _generate_temporary_login_link
def add_user(email, password, username=None, mobile_no=None):
@@ -42,6 +45,9 @@ class TestAuth(FrappeTestCase):
@classmethod
def tearDownClass(cls):
frappe.delete_doc("User", cls.test_user_email, force=True)
+ frappe.local.request_ip = None
+ frappe.form_dict.email = None
+ frappe.local.response["http_status_code"] = None
def set_system_settings(self, k, v):
frappe.db.set_value("System Settings", "System Settings", k, v)
@@ -123,6 +129,32 @@ class TestAuth(FrappeTestCase):
with self.assertRaises(Exception):
FrappeClient(self.HOST_NAME, self.test_user_email, self.test_user_password).get_list("ToDo")
+ def test_login_with_email_link(self):
+
+ user = self.test_user_email
+
+ # Logs in
+ res = requests.get(_generate_temporary_login_link(user, 10))
+ self.assertEqual(res.status_code, 200)
+ self.assertTrue(res.cookies.get("sid"))
+ self.assertNotEqual(res.cookies.get("sid"), "Guest")
+
+ # Random incorrect URL
+ res = requests.get(_generate_temporary_login_link(user, 10) + "aa")
+ self.assertEqual(res.cookies.get("sid"), "Guest")
+
+ # POST doesn't work
+ res = requests.post(_generate_temporary_login_link(user, 10))
+ self.assertEqual(res.status_code, 403)
+
+ # Rate limiting
+ for _ in range(6):
+ res = requests.get(_generate_temporary_login_link(user, 10))
+ if res.status_code == 417:
+ break
+ else:
+ self.fail("Rate limting not working")
+
class TestLoginAttemptTracker(FrappeTestCase):
def test_account_lock(self):
diff --git a/frappe/tests/test_rate_limiter.py b/frappe/tests/test_rate_limiter.py
index e93bf98875..c8485d6c69 100644
--- a/frappe/tests/test_rate_limiter.py
+++ b/frappe/tests/test_rate_limiter.py
@@ -13,9 +13,6 @@ from frappe.utils import cint
class TestRateLimiter(FrappeTestCase):
- def setUp(self):
- pass
-
def test_apply_with_limit(self):
frappe.conf.rate_limit = {"window": 86400, "limit": 1}
frappe.rate_limiter.apply()
diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html
index 67236fb26d..b5e2f8d4f8 100644
--- a/frappe/website/doctype/blog_post/templates/blog_post.html
+++ b/frappe/website/doctype/blog_post/templates/blog_post.html
@@ -22,7 +22,7 @@
{%- if read_time -%}
·
- {{ read_time }} min read
+ {{ read_time }} {{ _('min read') }}
{%- endif -%}
diff --git a/frappe/website/doctype/blog_post/templates/blog_post_row.html b/frappe/website/doctype/blog_post/templates/blog_post_row.html
index f8494d12b0..91beeb12e9 100644
--- a/frappe/website/doctype/blog_post/templates/blog_post_row.html
+++ b/frappe/website/doctype/blog_post/templates/blog_post_row.html
@@ -33,7 +33,7 @@
{{ post.full_name }}
{{ frappe.format_date(post.published_on) }}
- {% if post.read_time %} · {{ post.read_time }} min read {% endif %}
+ {% if post.read_time %} · {{ post.read_time }} {{ _('min read') }} {% endif %}
diff --git a/frappe/www/login.html b/frappe/www/login.html
index e9f2b9103a..1ac80bbee5 100644
--- a/frappe/www/login.html
+++ b/frappe/www/login.html
@@ -80,7 +80,7 @@
+ {% if login_with_email_link %}
+
+ {% endif %}
{% else %}
@@ -181,6 +190,38 @@
+
+
{% endblock %}
diff --git a/frappe/www/login.py b/frappe/www/login.py
index 32e4bb4344..ce65390f3c 100644
--- a/frappe/www/login.py
+++ b/frappe/www/login.py
@@ -7,7 +7,8 @@ from frappe import _
from frappe.auth import LoginManager
from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
from frappe.integrations.oauth2_logins import decoder_compat
-from frappe.utils import cint
+from frappe.rate_limiter import rate_limit
+from frappe.utils import cint, get_url
from frappe.utils.html_utils import get_icon_html
from frappe.utils.jinja import guess_is_path
from frappe.utils.oauth import (
@@ -102,6 +103,8 @@ def get_context(context):
context["login_label"] = f" {_('or')} ".join(login_label)
+ context["login_with_email_link"] = frappe.get_system_settings("login_with_email_link")
+
return context
@@ -143,3 +146,61 @@ def login_via_token(login_token):
redirect_post_login(
desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
)
+
+
+@frappe.whitelist(allow_guest=True)
+@rate_limit(limit=5, seconds=60 * 60)
+def send_login_link(email: str):
+
+ expiry = frappe.get_system_settings("login_with_email_link_expiry") or 10
+ link = _generate_temporary_login_link(email, expiry)
+
+ app_name = (
+ frappe.get_website_settings("app_name") or frappe.get_system_settings("app_name") or _("Frappe")
+ )
+
+ subject = _("Login To {0}").format(app_name)
+
+ frappe.sendmail(
+ subject=subject,
+ recipients=email,
+ template="login_with_email_link",
+ args={"link": link, "minutes": expiry, "app_name": app_name},
+ now=True,
+ )
+
+
+def _generate_temporary_login_link(email: str, expiry: int):
+ assert isinstance(email, str)
+
+ if not frappe.db.exists("User", email):
+ frappe.throw(
+ _("User with email address {0} does not exist").format(email), frappe.DoesNotExistError
+ )
+ key = frappe.generate_hash()
+ frappe.cache().set_value(f"one_time_login_key:{key}", email, expires_in_sec=expiry * 60)
+
+ return get_url(f"/api/method/frappe.www.login.login_via_key?key={key}")
+
+
+@frappe.whitelist(allow_guest=True, methods=["GET"])
+@rate_limit(limit=5, seconds=60 * 60)
+def login_via_key(key: str):
+ cache_key = f"one_time_login_key:{key}"
+ email = frappe.cache().get_value(cache_key)
+
+ if email:
+ frappe.cache().delete_value(cache_key)
+
+ frappe.local.login_manager.login_as(email)
+
+ redirect_post_login(
+ desk_user=frappe.db.get_value("User", frappe.session.user, "user_type") == "System User"
+ )
+ else:
+ frappe.respond_as_web_page(
+ _("Not Permitted"),
+ _("The link you trying to login is invalid or expired."),
+ http_status_code=403,
+ indicator_color="red",
+ )
diff --git a/socketio.js b/socketio.js
index 250f3744d1..67746ee84e 100644
--- a/socketio.js
+++ b/socketio.js
@@ -58,8 +58,8 @@ io.on("connection", function (socket) {
socket.join(get_site_room(socket));
}
- socket.on("list_update", function (doctype) {
- can_subscribe_list({
+ socket.on("doctype_subscribe", function (doctype) {
+ can_subscribe_doctype({
socket,
doctype,
callback: () => {
@@ -286,11 +286,11 @@ function can_subscribe_doc(args) {
});
}
-function can_subscribe_list(args) {
+function can_subscribe_doctype(args) {
if (!args) return;
if (!args.doctype) return;
request
- .get(get_url(args.socket, "/api/method/frappe.realtime.can_subscribe_list"))
+ .get(get_url(args.socket, "/api/method/frappe.realtime.can_subscribe_doctype"))
.type("form")
.query({
sid: args.socket.sid,
@@ -306,7 +306,7 @@ function can_subscribe_list(args) {
args.callback && args.callback(err, res);
return true;
}
- log("ERROR (can_subscribe_list): ", err, res);
+ log("ERROR (can_subscribe_doctype): ", err, res);
});
}