* feat: add user invitation doctype & related public methods * style(user-invitation): execute formatters & add semgrep comments * refactor(user-invitation): use `is` to compare `None` values * fix(user-invitation): skip fetching `after_accept` for default app * fix(user-invitation): translate email templates * fix(user-invitaton): return pending invites from invite by email api * refactor(user-invitation): improve code quality * fix(user-invitation): translate all error messages * refactor(user-invitation): improve security & readability Improvements: - move invite expiration check to `daily_maintenance` - explicitly import all of the used packages - specify methods for all security-critical endpoints - improve error messages and give them suitable titles - remove unnecessary utility functions - make invitation key management secure - translate all of the subjects of the sent emails - use the `app_title` hook to create email titles - commit the work done after each iteration of the background invitation expiry checker - restructure code to improve readability - use `user.reset_password` to generate the target link - use clear long names to name identifiers - add document states with relevant colors (User Invitation doctype) - differ `sendmail` emails whenever possible - send an email to the invitation creator instead of the invitee after the invite has expired - remove `User Invitation Manager` role * fix(user-invitation): use valid emails to test doctype & related code * feat(user-invitation): support adding multiple roles * refactor(user-invitation): mark relevant fields `set only once` * feat(user-invitation): add `Cancelled` status * test(user-invitation): correct broken tests * test(user-invitation): form valid f-strings & run code formatter * feat(user-invitation): make doctype usable from desk * fix(user-invitation): remove delete permission from invitation doctype * feat(user-invitation): pass user inserted info to `after_accept` hook * refactor(user-invitation): improve custom action methods & errors Improvements: - trigger actions only when the invitation is in the `Pending` state - use lowercase letters to start error messages - handle cases where `user_invitation_hook` is not defined * refactor(user-invitation): remove site name from email templates * docs(user-invitation): add internal documentation * feat(user-invitation): add 'get pending' & cancel invites apis * fix(user-invitation): make invitation app specific * refactor(user-invitation): avoid mixing function programming * fix(user-invitation): make apis usable for app specific valid users * fix(user-invitation): allow app specific invites * feat(user-invitation): make list view & permission checks app specific * refactor(user-invitation): convert class methods to static when possible * feat(user-invitation): add `app_only_for` method to the doc * fix(user-invitation): f-string syntax error in `get_permission_query_conditions` * docs(user-invitation): add examples & improve the internal doc * refactor: rename method name static_ is unnecessary only_for doesn't make sense in this context when arguments are not roles * fix: Support POST request too We dont follow REST semantics 100%, anything that modifies something should ideally be doable with POST too. * chore: cap * fix: Avoid ignore_permissions as user arg --------- Co-authored-by: Ankush Menat <ankush@frappe.io>
126 lines
4 KiB
Python
126 lines
4 KiB
Python
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
|