diff --git a/frappe/core/api/user_invitation.py b/frappe/core/api/user_invitation.py new file mode 100644 index 0000000000..09811a9ace --- /dev/null +++ b/frappe/core/api/user_invitation.py @@ -0,0 +1,126 @@ +import frappe +import frappe.utils +from frappe import _ +from frappe.core.doctype.user_invitation.user_invitation import UserInvitation + + +@frappe.whitelist(methods=["POST"]) +def invite_by_email( + emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe" +) -> dict[str, list[str]]: + UserInvitation.validate_role(app_name) + + # validate emails + frappe.utils.validate_email_address(emails, throw=True) + email_list = frappe.utils.split_emails(emails) + if not email_list: + frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite")) + + # get relevant data from the database + accepted_invite_emails = frappe.db.get_all( + "User Invitation", + filters={"email": ["in", email_list], "status": "Accepted", "app_name": app_name}, + pluck="email", + ) + pending_invite_emails = frappe.db.get_all( + "User Invitation", + filters={"email": ["in", email_list], "status": "Pending", "app_name": app_name}, + pluck="email", + ) + + # create invitation documents + to_invite = list(set(email_list) - set(accepted_invite_emails) - set(pending_invite_emails)) + for email in to_invite: + frappe.get_doc( + doctype="User Invitation", + email=email, + roles=[dict(role=role) for role in roles], + app_name=app_name, + redirect_to_path=redirect_to_path, + ).insert(ignore_permissions=True) + + return { + "accepted_invite_emails": accepted_invite_emails, + "pending_invite_emails": pending_invite_emails, + "invited_emails": to_invite, + } + + +@frappe.whitelist(allow_guest=True, methods=["GET"]) +def accept_invitation(key: str) -> None: + _accept_invitation(key, False) + + +# `app_name` is required for security +@frappe.whitelist(methods=["PATCH", "POST"]) +def cancel_invitation(name: str, app_name: str): + UserInvitation.validate_role(app_name) + + if not frappe.db.exists("User Invitation", name): + frappe.throw(title=_("Error"), msg=_("Invitation not found")) + + invitation = frappe.get_doc("User Invitation", name) + if invitation.app_name != app_name: + # message is not specific enough for security + frappe.throw(title=_("Error"), msg=_("Invitation not found")) + + if invitation.status == "Cancelled": + return {"cancelled_now": False} + + if invitation.status != "Pending": + frappe.throw(title=_("Error"), msg=_("Invitation cannot be cancelled")) + + invitation.flags.ignore_permissions = True + return {"cancelled_now": invitation.cancel_invite()} + + +@frappe.whitelist(methods=["GET"]) +def get_pending_invitations(app_name: str): + UserInvitation.validate_role(app_name) + + pending_invitations = frappe.db.get_all( + "User Invitation", fields=["name", "email"], filters={"status": "Pending", "app_name": app_name} + ) + res = [] + for pending_invitation in pending_invitations: + roles = frappe.db.get_all("User Role", fields=["role"], filters={"parent": pending_invitation.name}) + res.append( + { + "name": pending_invitation.name, + "email": pending_invitation.email, + "roles": [r.role for r in roles], + } + ) + return res + + +def _accept_invitation(key: str, in_test: bool) -> None: + # get invitation + hashed_key = frappe.utils.sha256_hash(key) + invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key}) + if not invitation_name: + frappe.throw(title=_("Error"), msg=_("Invalid key")) + invitation = frappe.get_doc("User Invitation", invitation_name) + + # accept invitation + invitation.accept(ignore_permissions=True) + + user = frappe.get_doc("User", invitation.email) + should_update_password = not user.last_password_reset_date and not bool( + frappe.get_system_settings("disable_user_pass_login") + ) + + # set redirect_to + redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path()) + if should_update_password: + redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}" + + # GET requests do not cause an implicit commit + frappe.db.commit() # nosemgrep + + if not in_test and not should_update_password: + frappe.local.login_manager.login_as(invitation.email) + + # set response + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = redirect_to diff --git a/frappe/core/doctype/user_invitation/__init__.py b/frappe/core/doctype/user_invitation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/user_invitation/internal_doc/cancel_invitation_api_example.png b/frappe/core/doctype/user_invitation/internal_doc/cancel_invitation_api_example.png new file mode 100644 index 0000000000..b1cdc73a69 Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/cancel_invitation_api_example.png differ diff --git a/frappe/core/doctype/user_invitation/internal_doc/get_pending_invitations_api_example.png b/frappe/core/doctype/user_invitation/internal_doc/get_pending_invitations_api_example.png new file mode 100644 index 0000000000..f5994fa6df Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/get_pending_invitations_api_example.png differ diff --git a/frappe/core/doctype/user_invitation/internal_doc/index.md b/frappe/core/doctype/user_invitation/internal_doc/index.md new file mode 100644 index 0000000000..2e3fa127bf --- /dev/null +++ b/frappe/core/doctype/user_invitation/internal_doc/index.md @@ -0,0 +1,106 @@ +# User Invitation + +## Index + +- [Motivation](#motivation) +- [How to use it?](#how-to-use-it) +- [Whitelisted functions](#whitelisted-functions) + - [`invite_by_email`](#invite_by_email) + - [`accept_invitation`](#accept_invitation) + - [`get_pending_invitations`](#get_pending_invitations) + - [`cancel_invitation`](#cancel_invitation) +- [Normal flow](#normal-flow) +- [Important points](#important-points) + +## Motivation + +- Until now, there was no way to invite and create a new user based on a sent invitation that can be accepted or rejected by the invitee. +- Due to this, custom Framework applications have to implement a user invitation flow. But most of the rules around this flow are generic enough to let Framework store all of the common logic associated with a typical user invitation flow. +- This will help ensure consistency and prevent code duplication for custom Framework applications that need this type of feature. + +## How to use it? + +Define user invitation hooks in your app's `hooks.py` file. An example is shown below. + +![user invitation hooks example](./user_invitation_hooks_example.png) + +- `only_for` + + Roles that are allowed to invite users to your app. + +- `allowed_roles` + + Roles that are allowed to be invited to your app. + +- `after_accept` + + Dot path of the function to execute after the user accepts the invitation. + + ```python + from frappe.model.document import Document + + def after_accept( + invitation: Document, + user: Document, + user_inserted: bool + ) -> None: + # your business logic here + ``` + +> `after_accept` is optional and should be used only if required. + +At this point, you can start using the whitelisted functions under the `apis` section (`frappe/core/api/user_invitation.py`). For more information, read [whitelisted functions](#whitelisted-functions). + +By default, only `System Manager`s can create a new invitation, view the list of invitations, or view more details associated with a single invitation **using the desk**. To enable users with specific roles to perform the mentioned actions, you might want to provide `create`, `read`, and `write` access to the relevant roles. + +Example - If a user having the `Agent Manager` role should be able to use all of the user invitation features using the desk, these should be enabled: + +- User Invitation doctype + ![user invitation doctype's role permissions manager entry](./user_invitation_doc_role_permissions_manager.png) + +- Role doctype + ![role doctype's role permissions manager entry](./role_doc_role_permissions_manager.png) + +## Whitelisted functions + +There are a few whitelisted functions that can be used to manage invitations. All of the whitelisted functions are in `frappe/core/api/user_invitation.py`. + +### `invite_by_email` + +Invite new emails to your application. + +![invite by email api example](./invite_by_email_api_example.png) + +> The invited email will receive an email with a link to accept the invitation. + +### `accept_invitation` + +Enables invitees to accept the sent invitations. + +> This function should not be used directly. The only reason this function is whitelisted is because the sent invitations contain a link that the invitees use to accept the invitations. + +### `get_pending_invitations` + +Get all of the pending invitations associated with an installed Framework application. + +![get pending invitations api example](./get_pending_invitations_api_example.png) + +### `cancel_invitation` + +Cancels a specific pending invitation associated with an installed Framework application. + +![cancel invitation api example](./cancel_invitation_api_example.png) + +## Normal flow + +1. Invitations are created from the desk or by using the [`invite_by_email`](#invite_by_email) whitelisted function. An email is sent to the invited email with a link to accept the invitation. +2. The app administrator or anyone able to use the desk can cancel invitations. Once an invitation is cancelled, an email is sent to the creator of the invitation. +3. Once the invitation is accepted, a new user is created (if required) with the roles specified in the invitation and is redirected to the specified path. +4. If the invitee doesn't accept the invitation within three days, the invitation is marked as expired by a background job that executes every day. Currently, there is no way to customize the expiration time. + +## Important points: + +- There can't be multiple pending invitations for the same app. +- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`. +- To manually mark an invitation as expired, you can use the `expire` method on the invitation document. +- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document. diff --git a/frappe/core/doctype/user_invitation/internal_doc/invite_by_email_api_example.png b/frappe/core/doctype/user_invitation/internal_doc/invite_by_email_api_example.png new file mode 100644 index 0000000000..47856f1c5e Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/invite_by_email_api_example.png differ diff --git a/frappe/core/doctype/user_invitation/internal_doc/role_doc_role_permissions_manager.png b/frappe/core/doctype/user_invitation/internal_doc/role_doc_role_permissions_manager.png new file mode 100644 index 0000000000..ba8eb32b81 Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/role_doc_role_permissions_manager.png differ diff --git a/frappe/core/doctype/user_invitation/internal_doc/user_invitation_doc_role_permissions_manager.png b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_doc_role_permissions_manager.png new file mode 100644 index 0000000000..d14794c081 Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_doc_role_permissions_manager.png differ diff --git a/frappe/core/doctype/user_invitation/internal_doc/user_invitation_hooks_example.png b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_hooks_example.png new file mode 100644 index 0000000000..8bc0fcd839 Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_hooks_example.png differ diff --git a/frappe/core/doctype/user_invitation/test_user_invitation.py b/frappe/core/doctype/user_invitation/test_user_invitation.py new file mode 100644 index 0000000000..a8342c87c4 --- /dev/null +++ b/frappe/core/doctype/user_invitation/test_user_invitation.py @@ -0,0 +1,254 @@ +# Copyright (c) 2025, Frappe Technologies and Contributors +# See license.txt + +import re + +import frappe +import frappe.utils +from frappe.core.api.user_invitation import ( + _accept_invitation, + cancel_invitation, + get_pending_invitations, + invite_by_email, +) +from frappe.core.doctype.user_invitation.user_invitation import mark_expired_invitations +from frappe.tests import IntegrationTestCase + +emails = [ + "test_user_invite1@example.com", + "test_user_invite2@example.com", + "test_user_invite3@example.com", + "test_user_invite4@example.com", + "test_user_invite5@example.com", +] + + +class IntegrationTestUserInvitation(IntegrationTestCase): + """ + Integration tests for UserInvitation. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + user = frappe.new_doc("User") + user.first_name = "Test" + user.last_name = "123" + user.email = emails[0] + user.append_roles("System Manager") + user.insert() + frappe.set_user(emails[0]) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + IntegrationTestUserInvitation.delete_all_invitations() + IntegrationTestUserInvitation.delete_all_user_roles() + frappe.db.delete("Email Queue") + for user_email in emails: + if frappe.db.exists("User", user_email): + frappe.delete_doc("User", user_email) + frappe.set_user("Administrator") + # some of the code under test commit internally + frappe.db.commit() # nosemgrep + + @classmethod + def delete_all_user_roles(cls): + frappe.db.sql("DELETE FROM `tabUser Role`") + + @classmethod + def delete_all_invitations(cls): + frappe.db.sql("DELETE FROM `tabUser Invitation`") + + @classmethod + def delete_invitation(cls, name: str): + frappe.db.sql(f'DELETE FROM `tabUser Invitation` WHERE name = "{name}"') + + def setUp(self): + super().setUp() + IntegrationTestUserInvitation.delete_all_invitations() + IntegrationTestUserInvitation.delete_all_user_roles() + frappe.db.delete("Email Queue") + + def test_insert_invitation(self): + invitation = self.get_dummy_invitation() + self.assertEqual(len(self.get_email_names()), 0) + invitation.insert() + self.assertEqual(invitation.invited_by, frappe.session.user) + self.assertEqual(invitation.status, "Pending") + self.assertIsInstance(invitation.email_sent_at, str) + self.assertIsInstance(invitation.key, str) + self.assertIsInstance(invitation.roles, list) + sent_emails = self.get_email_messages() + self.assertEqual(len(sent_emails), 1) + self.assertIn("invited", sent_emails[0].message.lower()) + + def test_update_invitation_status_to_expired(self): + invitation = self.get_dummy_invitation() + invitation.insert() + self.assertEqual(len(self.get_email_names()), 1) + invitation.expire() + emails = self.get_email_messages(False) + self.assertEqual(len(emails), 2) + self.assertIn("expired", emails[0].message.lower()) + + def test_cancel_pending_invitation(self): + invitation = self.get_dummy_invitation() + invitation.insert() + self.assertEqual(len(self.get_email_names(False)), 1) + self.assertEqual(invitation.status, "Pending") + invitation.cancel_invite() + sent_emails = self.get_email_messages(False) + self.assertEqual(len(sent_emails), 2) + self.assertIn("cancelled", sent_emails[0].message.lower()) + + def test_cancel_accepted_invitation(self): + invitation = self.get_dummy_invitation() + invitation.insert() + self.assertEqual(len(self.get_email_names(False)), 1) + invitation.status = "Accepted" + invitation.save() + invitation.cancel_invite() + self.assertEqual(len(self.get_email_names(False)), 1) + + def test_cancel_expired_invitation(self): + invitation = self.get_dummy_invitation() + invitation.insert() + self.assertEqual(len(self.get_email_names(False)), 1) + invitation.expire() + self.assertEqual(len(self.get_email_names(False)), 2) + invitation.cancel_invite() + self.assertEqual(len(self.get_email_names(False)), 2) + + def test_mark_expired_invitations(self): + invitation = self.get_dummy_invitation() + invitation.insert() + # the status of invitations older than 3 days should be set to expired + invitation.db_set("creation", frappe.utils.add_days(frappe.utils.now(), -4)) + mark_expired_invitations() + invitation.reload() + self.assertEqual(invitation.status, "Expired") + + def test_invite_by_email_api(self): + accepted_invite_email = emails[1] + invitation = frappe.get_doc( + doctype="User Invitation", + email=accepted_invite_email, + roles=[dict(role="System Manager")], + redirect_to_path="/abc", + app_name="frappe", + ).insert() + invitation.status = "Accepted" + invitation.save() + self.assertEqual(len(self.get_email_names(False)), 1) + pending_invite_email = emails[2] + frappe.get_doc( + doctype="User Invitation", + email=pending_invite_email, + roles=[dict(role="System Manager")], + redirect_to_path="/abc", + app_name="frappe", + ).insert() + self.assertEqual(len(self.get_email_names(False)), 2) + email_to_invite = emails[3] + res = invite_by_email( + emails=", ".join([accepted_invite_email, pending_invite_email, email_to_invite]), + roles=["System Manager"], + redirect_to_path="/xyz", + ) + self.assertSequenceEqual(res["accepted_invite_emails"], [accepted_invite_email]) + self.assertSequenceEqual(res["pending_invite_emails"], [pending_invite_email]) + self.assertSequenceEqual(res["invited_emails"], [email_to_invite]) + self.assertEqual(len(self.get_email_names(False)), 3) + + def test_accept_invitation_api_pass_redirect(self): + invitation = frappe.get_doc( + doctype="User Invitation", + email=emails[1], + roles=[dict(role="System Manager")], + redirect_to_path="/abc", + app_name="frappe", + ).insert() + self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0) + self.assertEqual(len(self.get_email_names(False)), 1) + key = invitation._after_insert() + self.assertEqual(len(self.get_email_names(False)), 2) + _accept_invitation(key, True) + res = frappe.local.response + self.assertEqual(res.type, "redirect") + pattern = f"^{re.escape(frappe.utils.get_url(''))}/update-password\\?key=.+&redirect_to=/abc$" + self.assertRegex(res.location, pattern) + user = frappe.get_doc("User", invitation.email) + IntegrationTestUserInvitation.delete_invitation(invitation.name) + frappe.delete_doc("User", user.name) + + def test_accept_invitation_api_direct_redirect(self): + invitation = frappe.get_doc( + doctype="User Invitation", + email=emails[1], + roles=[dict(role="System Manager")], + redirect_to_path="/abc", + app_name="frappe", + ).insert() + self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0) + original_disable_user_pass_login = frappe.get_system_settings("disable_user_pass_login") + frappe.db.set_single_value("System Settings", "disable_user_pass_login", 1) + self.assertEqual(len(self.get_email_names(False)), 1) + key = invitation._after_insert() + self.assertEqual(len(self.get_email_names(False)), 2) + _accept_invitation(key, True) + frappe.db.set_single_value( + "System Settings", "disable_user_pass_login", original_disable_user_pass_login + ) + res = frappe.local.response + self.assertEqual(res.type, "redirect") + pattern = f"^{re.escape(frappe.utils.get_url(''))}/abc$" + self.assertRegex(res.location, pattern) + user = frappe.get_doc("User", invitation.email) + IntegrationTestUserInvitation.delete_invitation(invitation.name) + frappe.delete_doc("User", user.name) + + def test_get_pending_invitations_api(self): + invitation = self.get_dummy_invitation() + invitation.insert() + invitation.reload() + pending_invitations = get_pending_invitations("frappe") + self.assertEqual(len(pending_invitations), 1) + pending_invitation = pending_invitations[0] + self.assertEqual(pending_invitation["name"], invitation.name) + self.assertEqual(pending_invitation["email"], invitation.email) + roles = pending_invitation["roles"] + self.assertIsInstance(roles, list) + self.assertSequenceEqual(roles, [r.role for r in invitation.roles]) + + def test_cancel_invitation_api(self): + invitation = self.get_dummy_invitation() + invitation.insert() + invitation.reload() + self.assertEqual(invitation.status, "Pending") + self.assertEqual(len(self.get_email_names()), 1) + res = cancel_invitation(invitation.name, "frappe") + self.assertTrue(res["cancelled_now"]) + invitation.reload() + self.assertEqual(invitation.status, "Cancelled") + self.assertEqual(len(self.get_email_names()), 2) + res = cancel_invitation(invitation.name, "frappe") + self.assertFalse(res["cancelled_now"]) + self.assertEqual(len(self.get_email_names()), 2) + + def get_dummy_invitation(self): + return frappe.get_doc( + doctype="User Invitation", + email=emails[1], + roles=[dict(role="System Manager")], + redirect_to_path="/abc", + app_name="frappe", + ) + + def get_email_names(self, sent_only=True): + filters = {"status": "Sent"} if sent_only else None + return frappe.db.get_all("Email Queue", filters=filters, fields=["name"]) + + def get_email_messages(self, sent_only=True): + filters = {"status": "Sent"} if sent_only else None + return frappe.db.get_all("Email Queue", filters=filters, fields=["message"]) diff --git a/frappe/core/doctype/user_invitation/user_invitation.js b/frappe/core/doctype/user_invitation/user_invitation.js new file mode 100644 index 0000000000..d60e28c031 --- /dev/null +++ b/frappe/core/doctype/user_invitation/user_invitation.js @@ -0,0 +1,23 @@ +// Copyright (c) 2025, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("User Invitation", { + refresh(frm) { + frappe.xcall("frappe.apps.get_apps").then((r) => { + const apps = r?.map((r) => r.name) ?? []; + const default_app = "frappe"; + frm.set_df_property("app_name", "options", [default_app, ...apps]); + if (!frm.doc.app_name) { + frm.set_value("app_name", default_app); + } + }); + if (frm.doc.__islocal || frm.doc.status !== "Pending") { + return; + } + frm.add_custom_button(__("Cancel"), () => { + frappe.confirm(__("Are you sure you want to cancel the invitation?"), () => + frm.call("cancel_invite") + ); + }); + }, +}); diff --git a/frappe/core/doctype/user_invitation/user_invitation.json b/frappe/core/doctype/user_invitation/user_invitation.json new file mode 100644 index 0000000000..dffe32e82a --- /dev/null +++ b/frappe/core/doctype/user_invitation/user_invitation.json @@ -0,0 +1,143 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-07-07 14:19:31.014655", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "email", + "app_name", + "redirect_to_path", + "roles", + "status", + "invited_by", + "key", + "user", + "email_sent_at", + "accepted_at" + ], + "fields": [ + { + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "invited_by", + "fieldtype": "Link", + "hidden": 1, + "in_list_view": 1, + "label": "Invited By", + "options": "User", + "read_only": 1 + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "in_list_view": 1, + "label": "Status", + "options": "Pending\nAccepted\nExpired\nCancelled", + "read_only": 1 + }, + { + "fieldname": "email_sent_at", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Email Sent At", + "read_only": 1 + }, + { + "fieldname": "accepted_at", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Accepted At", + "read_only": 1 + }, + { + "fieldname": "user", + "fieldtype": "Link", + "hidden": 1, + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "app_name", + "fieldtype": "Select", + "in_list_view": 1, + "label": "App Name", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "redirect_to_path", + "fieldtype": "Data", + "label": "Redirect To Path", + "read_only_depends_on": "eval:doc.status!==\"Pending\"", + "reqd": 1 + }, + { + "fieldname": "key", + "fieldtype": "Data", + "hidden": 1, + "label": "Key", + "read_only": 1 + }, + { + "fieldname": "roles", + "fieldtype": "Table MultiSelect", + "label": "Roles", + "options": "User Role", + "read_only_depends_on": "eval:Boolean(doc.creation)", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-07-26 11:52:46.984800", + "modified_by": "Administrator", + "module": "Core", + "name": "User Invitation", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [ + { + "color": "Green", + "title": "Accepted" + }, + { + "color": "Orange", + "title": "Pending" + }, + { + "color": "Yellow", + "title": "Expired" + }, + { + "color": "Red", + "title": "Cancelled" + } + ] +} diff --git a/frappe/core/doctype/user_invitation/user_invitation.py b/frappe/core/doctype/user_invitation/user_invitation.py new file mode 100644 index 0000000000..cccb8749a3 --- /dev/null +++ b/frappe/core/doctype/user_invitation/user_invitation.py @@ -0,0 +1,241 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +import frappe.utils +from frappe import _ +from frappe.model.document import Document +from frappe.permissions import get_roles + + +class UserInvitation(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.core.doctype.user_role.user_role import UserRole + from frappe.types import DF + + accepted_at: DF.Datetime | None + app_name: DF.Literal[None] + email: DF.Data + email_sent_at: DF.Datetime | None + invited_by: DF.Link | None + key: DF.Data | None + redirect_to_path: DF.Data + roles: DF.TableMultiSelect[UserRole] + status: DF.Literal["Pending", "Accepted", "Expired", "Cancelled"] + user: DF.Link | None + # end: auto-generated types + + def before_insert(self): + self._validate_invite() + self.invited_by = frappe.session.user + self.status = "Pending" + + def after_insert(self): + self._after_insert() + + def accept(self, ignore_permissions: bool = False): + accepted_now = self._accept() + if not accepted_now: + return + user, user_inserted = self._upsert_user(ignore_permissions) + self.save(ignore_permissions) + user.save(ignore_permissions) + self._run_after_accept_hooks(user, user_inserted) + + @frappe.whitelist() + def cancel_invite(self): + if self.status != "Pending": + return False + self.status = "Cancelled" + self.save() + email_title = self._get_email_title() + frappe.sendmail( + recipients=self.email, + subject=_("Invitation to join {0} cancelled").format(email_title), + template="user_invitation_cancelled", + args={"title": email_title}, + now=True, + ) + return True + + @frappe.whitelist() + def expire(self): + if self.status != "Pending": + return + self.status = "Expired" + self.save() + email_title = self._get_email_title() + invited_by_user = frappe.get_doc("User", self.invited_by) + frappe.sendmail( + recipients=invited_by_user.email, + subject=_("Invitation to join {0} expired").format(email_title), + template="user_invitation_expired", + args={"title": email_title}, + now=False, + ) + + def _validate_invite(self): + self._validate_app_name() + self._validate_roles() + self._validate_email() + if frappe.db.get_value( + "User Invitation", filters={"email": self.email, "status": "Accepted", "app_name": self.app_name} + ): + frappe.throw(title=_("Error"), msg=_("invitation already accepted")) + if frappe.db.get_value( + "User Invitation", filters={"email": self.email, "status": "Pending", "app_name": self.app_name} + ): + frappe.throw(title=_("Error"), msg=_("invitation already exists")) + + def _after_insert(self): + key = frappe.generate_hash() + self.db_set("key", frappe.utils.sha256_hash(key)) + invite_link = frappe.utils.get_url( + f"/api/method/frappe.core.api.user_invitation.accept_invitation?key={key}" + ) + email_title = self._get_email_title() + frappe.sendmail( + recipients=self.email, + subject=_("You've been invited to join {0}").format(email_title), + template="user_invitation", + args={"title": email_title, "invite_link": invite_link}, + now=True, + ) + self.db_set("email_sent_at", frappe.utils.now()) + return key + + def _accept(self): + if self.status == "Accepted": + return False + if self.status == "Expired": + frappe.throw(title=_("Error"), msg=_("Invitation is expired")) + if self.status == "Cancelled": + frappe.throw(title=_("Error"), msg=_("Invitation is cancelled")) + self.status = "Accepted" + self.accepted_at = frappe.utils.now() + self.user = self.email + return True + + def _upsert_user(self, ignore_permissions: bool = False): + user: Document | None = None + user_inserted = False + if frappe.db.exists("User", self.user): + user = frappe.get_doc("User", self.user) + else: + user = frappe.new_doc("User") + user.user_type = "System User" + user.email = self.email + user.first_name = self.email.split("@")[0].title() + user.send_welcome_email = False + user.insert(ignore_permissions) + user_inserted = True + user.append_roles(*[r.role for r in self.roles]) + return user, user_inserted + + def _run_after_accept_hooks(self, user: Document, user_inserted: bool): + user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name) + if not isinstance(user_invitation_hook, dict): + return + for dot_path in user_invitation_hook.get("after_accept") or []: + frappe.call(dot_path, invitation=self, user=user, user_inserted=user_inserted) + + def _get_email_title(self): + return frappe.get_hooks("app_title", app_name=self.app_name)[0] + + def _validate_app_name(self): + UserInvitation.validate_app_name(self.app_name) + + def _validate_roles(self): + if self.app_name == "frappe": + return + user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name) + allowed_roles: list[str] = [] + if isinstance(user_invitation_hook, dict): + allowed_roles = user_invitation_hook.get("allowed_roles") or [] + for r in self.roles: + if r.role in allowed_roles: + continue + frappe.throw( + title=_("Invalid role"), + msg=_("{0} is not an allowed role for {1}").format(r.role, self.app_name), + ) + + def _validate_email(self): + frappe.utils.validate_email_address(self.email, throw=True) + + def get_redirect_to_path(self): + start_index = 1 if self.redirect_to_path.startswith("/") else 0 + return self.redirect_to_path[start_index:] + + @staticmethod + def validate_app_name(app_name: str): + if app_name not in frappe.get_installed_apps(): + frappe.throw(title=_("Invalid app"), msg=_("application is not installed")) + + @staticmethod + def validate_role(app_name: str) -> None: + UserInvitation.validate_app_name(app_name) + user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name) + only_for: list[str] = [] + if isinstance(user_invitation_hook, dict): + only_for = user_invitation_hook.get("only_for") or [] + if "System Manager" not in only_for: + only_for.append("System Manager") + frappe.only_for(only_for) + + +def mark_expired_invitations() -> None: + days = 3 + invitations_to_expire = frappe.db.get_all( + "User Invitation", + filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]}, + ) + for invitation in invitations_to_expire: + invitation = frappe.get_doc("User Invitation", invitation.name) + invitation.expire() + # to avoid losing work in case the job times out without finishing + frappe.db.commit() # nosemgrep + + +def get_allowed_apps(user: Document | None) -> list[str]: + user_roles = set(get_user_roles(user)) + allowed_apps: list[str] = [] + for app in frappe.get_installed_apps(): + user_invitation_hooks = frappe.get_hooks("user_invitation", app_name=app) + if not isinstance(user_invitation_hooks, dict): + continue + only_for = user_invitation_hooks.get("only_for") or [] + if set(only_for) & user_roles: + allowed_apps.append(app) + return allowed_apps + + +def get_permission_query_conditions(user: Document | None) -> str | None: + user = get_user(user) + user_roles = get_user_roles(user) + if "System Manager" in user_roles: + return + allowed_apps = get_allowed_apps(user) + if not allowed_apps: + return "false" + allowed_apps_str = ", ".join([f'"{app}"' for app in allowed_apps]) + return f"`tabUser Invitation`.app_name IN ({allowed_apps_str})" + + +def has_permission( + doc: UserInvitation, user: Document | None = None, permission_type: str | None = None +) -> bool: + return permission_type != "delete" and doc.app_name in get_allowed_apps(user) + + +def get_user_roles(user: Document | None) -> list[str]: + return get_roles(get_user(user)) + + +def get_user(user: Document | None) -> Document: + return user or frappe.session.user diff --git a/frappe/core/doctype/user_role/__init__.py b/frappe/core/doctype/user_role/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/user_role/user_role.json b/frappe/core/doctype/user_role/user_role.json new file mode 100644 index 0000000000..00fb33ea9a --- /dev/null +++ b/frappe/core/doctype/user_role/user_role.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-07-17 10:56:04.746455", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role" + ], + "fields": [ + { + "fieldname": "role", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Role", + "options": "Role", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-07-17 10:56:36.357715", + "modified_by": "Administrator", + "module": "Core", + "name": "User Role", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/core/doctype/user_role/user_role.py b/frappe/core/doctype/user_role/user_role.py new file mode 100644 index 0000000000..6270a86312 --- /dev/null +++ b/frappe/core/doctype/user_role/user_role.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class UserRole(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + role: DF.Link + # end: auto-generated types + + pass diff --git a/frappe/hooks.py b/frappe/hooks.py index 284fe14676..ea4537ba0d 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -110,6 +110,7 @@ permission_query_conditions = { "Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions", "Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.get_permission_query_condition", "File": "frappe.core.doctype.file.file.get_permission_query_conditions", + "User Invitation": "frappe.core.doctype.user_invitation.user_invitation.get_permission_query_conditions", } has_permission = { @@ -127,6 +128,7 @@ has_permission = { "File": "frappe.core.doctype.file.file.has_permission", "Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.has_permission", "Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.has_permission", + "User Invitation": "frappe.core.doctype.user_invitation.user_invitation.has_permission", } has_website_permission = {"Address": "frappe.contacts.doctype.address.address.has_website_permission"} @@ -247,6 +249,7 @@ scheduler_events = { "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record", "frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry", "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", + "frappe.core.doctype.user_invitation.user_invitation.mark_expired_invitations", ], "weekly_long": [ "frappe.desk.form.document_follow.send_weekly_updates", @@ -564,3 +567,7 @@ persistent_cache_keys = [ "rate-limit-counter-*", "rl:*", ] + +user_invitation = { + "only_for": ["System Manager"], +} diff --git a/frappe/templates/emails/user_invitation.html b/frappe/templates/emails/user_invitation.html new file mode 100644 index 0000000000..d7928930fd --- /dev/null +++ b/frappe/templates/emails/user_invitation.html @@ -0,0 +1,20 @@ + + {{ _("Hello,") }} + + + {{ _("You've been invited to join {0}.").format(title) }} + + + {{ _("Click below to get started:") }} + + + {{ _("Accept Invitation") }} + + + {{ _("If you have any questions, reach out to your system administrator.") }} + diff --git a/frappe/templates/emails/user_invitation_cancelled.html b/frappe/templates/emails/user_invitation_cancelled.html new file mode 100644 index 0000000000..3b102c742c --- /dev/null +++ b/frappe/templates/emails/user_invitation_cancelled.html @@ -0,0 +1,9 @@ + + {{ _("Hello,") }} + + + {{ _("Your invitation to join {0} has been cancelled by the site administrator.").format(title) }} + + + {{ _("If this was a mistake or you need access again, please reach out to your team.") }} + diff --git a/frappe/templates/emails/user_invitation_expired.html b/frappe/templates/emails/user_invitation_expired.html new file mode 100644 index 0000000000..64e5200c97 --- /dev/null +++ b/frappe/templates/emails/user_invitation_expired.html @@ -0,0 +1,9 @@ + + {{ _("Hello,") }} + + + {{ _("Your invitation to join {0} has expired.").format(title) }} + + + {{ _("You can ask your team to resend the invitation if you'd still like to join.") }} +