Merge branch 'develop' into fix-auto-enable-in-list-view
This commit is contained in:
commit
215e9dc2ef
16 changed files with 249 additions and 24 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ ALLOWED_MIMETYPES = (
|
|||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/plain",
|
||||
"video/quicktime",
|
||||
"video/mp4",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
41
frappe/templates/emails/login_with_email_link.html
Normal file
41
frappe/templates/emails/login_with_email_link.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{% macro table(content, table_class) %}
|
||||
<table class="{{ table_class or '' }}" cellpadding="0" cellspacing="0" width="100%" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
{{ content }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro body() %}
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="email-header-title">
|
||||
{{ _('Click on the button to log in to {0}').format(app_name) }}
|
||||
</div>
|
||||
<div>{{ _('The link will expire in {0} minutes').format(minutes) }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="btn btn-primary" style="margin-top: 30px;">
|
||||
<a href="{{ link or '#'}}" style="color: #fff; text-decoration: none;">
|
||||
{{ _('Log In To {0}').format(app_name) }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="body-table with-container">
|
||||
<div class="body-content">
|
||||
{{ table(table(body(), 'email-body'), 'email-container') }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<time datetime="{{ published_on }}">{{ frappe.format_date(published_on) }}</time>
|
||||
{%- if read_time -%}
|
||||
·
|
||||
<span>{{ read_time }} min read</span>
|
||||
<span>{{ read_time }} {{ _('min read') }} </span>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
<a href="/blog?blogger={{ post.blogger }}">{{ post.full_name }}</a>
|
||||
<div class="small">
|
||||
{{ 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 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
<div class="login-content page-card">
|
||||
{{ logo_section() }}
|
||||
<form class="form-signin form-login" role="form">
|
||||
{%- if social_login -%}
|
||||
{%- if social_login or login_with_email_link -%}
|
||||
<div class="page-card-body">
|
||||
<form class="form-signin form-login" role="form">
|
||||
{{ email_login_body() }}
|
||||
|
|
@ -101,6 +101,15 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if login_with_email_link %}
|
||||
<div class="login-with-email-link">
|
||||
<div class="login-button-wrapper">
|
||||
<a href="#login-with-email-link"
|
||||
class="btn btn-block btn-default btn-sm btn-login-option btn-login-with-email-link">
|
||||
{{ _("Login with Email Link") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
@ -181,6 +190,38 @@
|
|||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class='for-login-with-email-link'>
|
||||
<div class="login-content page-card">
|
||||
<form class="form-signin form-login-with-email-link hide" role="form">
|
||||
<div class="page-card-head">
|
||||
<h4>{{ _('Login With Email Link') }}</h4>
|
||||
</div>
|
||||
<div class="page-card-body">
|
||||
<div class="email-field">
|
||||
<input type="email" id="login_with_email_link_email" class="form-control"
|
||||
placeholder="{{ _('Email Address') }}" required autofocus autocomplete="username">
|
||||
<svg class="field-icon email-icon" width="20" height="20" viewBox="0 0 20 20" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.5 7.65149V15.0757C2.5 15.4374 2.64367 15.7842 2.8994 16.04C3.15513 16.2957 3.50198 16.4394 3.86364 16.4394H16.1364C16.498 16.4394 16.8449 16.2957 17.1006 16.04C17.3563 15.7842 17.5 15.4374 17.5 15.0757V7.65149"
|
||||
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
|
||||
<path
|
||||
d="M17.5 7.57572V5.53026C17.5 5.1686 17.3563 4.82176 17.1006 4.56603C16.8449 4.31029 16.498 4.16663 16.1364 4.16663H3.86364C3.50198 4.16663 3.15513 4.31029 2.8994 4.56603C2.64367 4.82176 2.5 5.1686 2.5 5.53026V7.57572L10 10.8333L17.5 7.57572Z"
|
||||
stroke="#74808B" stroke-miterlimit="10" stroke-linecap="square" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-card-actions">
|
||||
<button class="btn btn-sm btn-primary btn-block btn-login-with-email-link"
|
||||
type="submit">{{ _("Send login link") }}</button>
|
||||
<p class="text-center sign-up-message">
|
||||
<a href="#login">{{ _("Back to Login") }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
10
socketio.js
10
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue