diff --git a/impersonate_login/README.rst b/impersonate_login/README.rst new file mode 100644 index 0000000000..1aa1d70da6 --- /dev/null +++ b/impersonate_login/README.rst @@ -0,0 +1,108 @@ +================= +Impersonate Login +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1fca331cbc5f2dcb804e5612e5669a9ab4998d80f22d46d6683266580f9ca40f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/14.0/impersonate_login + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-14-0/server-auth-14-0-impersonate_login + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows one user (for example, a member of the support team) +to log in as another user. The impersonation session can be exited by +clicking on the button "Back to Original User". + +To ensure that any abuse of this feature will not go unnoticed, the +following measures are in place: + +- In the chatter, it is displayed who is the user that is logged as + another user. +- Mails and messages are sent from the original user. +- Impersonated logins are logged and can be consulted through the + Settings -> Technical menu. +- + +There is an alternative module to allow logins as another user +(auth_admin_passkey), but it does not support these security mechanisms. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The impersonating user must belong to group "Impersonate Users". +- In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click "Switch Login" toi mpersonate another user. +- On the top-right corner, the button "Back to Original User" is displayed in case the current user is being impersonated. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +- Kévin Roche +- [360ERP](https://www.360erp.com): + - Andrea Stirpe + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-Kev-Roche| image:: https://github.com/Kev-Roche.png?size=40px + :target: https://github.com/Kev-Roche + :alt: Kev-Roche + +Current `maintainer `__: + +|maintainer-Kev-Roche| + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/impersonate_login/__init__.py b/impersonate_login/__init__.py new file mode 100644 index 0000000000..6d58305f5d --- /dev/null +++ b/impersonate_login/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/impersonate_login/__manifest__.py b/impersonate_login/__manifest__.py new file mode 100644 index 0000000000..35b39b0e6a --- /dev/null +++ b/impersonate_login/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Impersonate Login", + "summary": "tools", + "version": "14.0.1.0.0", + "category": "Tools", + "website": "https://github.com/OCA/server-auth", + "author": "Akretion, Odoo Community Association (OCA)", + "maintainers": ["Kev-Roche"], + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "web", + "mail", + ], + "data": [ + "views/assets.xml", + "views/res_users.xml", + "views/impersonate_log.xml", + "security/group.xml", + "security/ir.model.access.csv", + ], + "qweb": [ + "static/src/xml/user_menu.xml", + ], + "pre_init_hook": "pre_init_hook", +} diff --git a/impersonate_login/hooks.py b/impersonate_login/hooks.py new file mode 100644 index 0000000000..bbbe495bd2 --- /dev/null +++ b/impersonate_login/hooks.py @@ -0,0 +1,19 @@ +# Copyright 2024 360ERP () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + + +def pre_init_hook(cr): + """ + Pre-create the impersonated_author_id column in the mail_message table + to prevent the ORM from invoking its compute method on a large volume + of existing mail messages. + """ + logger = logging.getLogger(__name__) + logger.info("Add mail_message.impersonated_author_id column if not exists") + cr.execute( + "ALTER TABLE mail_message " + "ADD COLUMN IF NOT EXISTS " + "impersonated_author_id INTEGER" + ) diff --git a/impersonate_login/models/__init__.py b/impersonate_login/models/__init__.py new file mode 100644 index 0000000000..debb66e9c1 --- /dev/null +++ b/impersonate_login/models/__init__.py @@ -0,0 +1,6 @@ +from . import res_users +from . import ir_http +from . import mail_thread +from . import mail_message +from . import impersonate_log +from . import model diff --git a/impersonate_login/models/impersonate_log.py b/impersonate_login/models/impersonate_log.py new file mode 100644 index 0000000000..349c46eb69 --- /dev/null +++ b/impersonate_login/models/impersonate_log.py @@ -0,0 +1,26 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class ImpersonateLog(models.Model): + _name = "impersonate.log" + _description = "Impersonate Logs" + + user_id = fields.Many2one( + comodel_name="res.users", + string="User", + ) + impersonated_partner_id = fields.Many2one( + comodel_name="res.partner", + string="Logged as", + ) + date_start = fields.Datetime( + string="Start Date", + ) + date_end = fields.Datetime( + string="End Date", + ) diff --git a/impersonate_login/models/ir_http.py b/impersonate_login/models/ir_http.py new file mode 100644 index 0000000000..f01aa613d2 --- /dev/null +++ b/impersonate_login/models/ir_http.py @@ -0,0 +1,20 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class Http(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): + session_info = super().session_info() + session_info.update( + { + "is_impersonate_user": request.env.user._is_impersonate_user(), + "impersonate_from_uid": request.session.impersonate_from_uid, + } + ) + return session_info diff --git a/impersonate_login/models/mail_message.py b/impersonate_login/models/mail_message.py new file mode 100644 index 0000000000..e7bf2fd4dc --- /dev/null +++ b/impersonate_login/models/mail_message.py @@ -0,0 +1,79 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.http import request +from odoo.tools import html_escape + + +class Message(models.Model): + _inherit = "mail.message" + + impersonated_author_id = fields.Many2one( + comodel_name="res.partner", + compute="_compute_impersonated_author_id", + store=True, + ) + + body = fields.Html( + compute="_compute_message_body", + inverse="_inverse_message_body", + store=True, + readonly=False, + ) + + @api.depends("author_id") + def _compute_impersonated_author_id(self): + for rec in self: + if request and request.session.impersonate_from_uid: + rec.impersonated_author_id = ( + self.env["res.users"] + .browse(request.session.impersonate_from_uid) + .partner_id.id + ) + else: + rec.impersonated_author_id = False + + @api.depends("author_id", "impersonated_author_id") + def _compute_message_body(self): + for rec in self: + additional_info = "" + if ( + request + and request.session.impersonate_from_uid + and rec.impersonated_author_id + ): + current_partner = ( + self.env["res.users"].browse(request.session.uid).partner_id + ) + additional_info = _("Logged in as {}").format( + html_escape(current_partner.name) + ) + if rec.body and additional_info: + rec.body = f"{additional_info}
{rec.body}" + else: + rec.body = rec.body + + def _inverse_message_body(self): + for rec in self: + additional_info = "" + if ( + request + and request.session.impersonate_from_uid + and rec.impersonated_author_id + ): + current_partner = ( + self.env["res.users"].browse(request.session.uid).partner_id + ) + additional_info = _("Logged in as {}").format( + html_escape(current_partner.name) + ) + if additional_info: + start_with = f"{additional_info}
" + if rec.body and rec.body.startswith(start_with): + rec.body = rec.body + else: + rec.body = f"{start_with}{rec.body}" + else: + rec.body = rec.body diff --git a/impersonate_login/models/mail_thread.py b/impersonate_login/models/mail_thread.py new file mode 100644 index 0000000000..3fdd4a86d0 --- /dev/null +++ b/impersonate_login/models/mail_thread.py @@ -0,0 +1,30 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class MailThread(models.AbstractModel): + _inherit = "mail.thread" + + def _message_compute_author( + self, author_id=None, email_from=None, raise_exception=True + ): + if request and request.session.impersonate_from_uid: + author = self.env["res.users"].browse(request.session.uid).partner_id + if author_id == author.id or author_id is None: + impersonate_from_author = ( + self.env["res.users"] + .browse(request.session.impersonate_from_uid) + .partner_id + ) + email = impersonate_from_author.email_formatted + return impersonate_from_author.id, email + + return super()._message_compute_author( + author_id, + email_from, + raise_exception, + ) diff --git a/impersonate_login/models/model.py b/impersonate_login/models/model.py new file mode 100644 index 0000000000..16ac8a5a94 --- /dev/null +++ b/impersonate_login/models/model.py @@ -0,0 +1,44 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from psycopg2.extensions import AsIs + +from odoo import api, models +from odoo.http import request + + +class BaseModel(models.AbstractModel): + _inherit = "base" + + @api.model + def _create(self, data_list): + res = super()._create(data_list) + if ( + request + and request.session.impersonate_from_uid + and "create_uid" in self._fields + ): + self.env.cr.execute( + """ + UPDATE %(table)s + SET create_uid = %(impersonator_id)s + WHERE id IN %(record_ids)s + """, + { + "table": AsIs(self._table), + "impersonator_id": request.session.impersonate_from_uid, + "record_ids": tuple(rec.id for rec in res), + }, + ) + return res + + def write(self, vals): + res = super().write(vals) + if ( + request + and request.session.impersonate_from_uid + and "write_uid" in self._fields + ): + self._fields["write_uid"].write(self, request.session.impersonate_from_uid) + return res diff --git a/impersonate_login/models/res_users.py b/impersonate_login/models/res_users.py new file mode 100644 index 0000000000..c3307dddd7 --- /dev/null +++ b/impersonate_login/models/res_users.py @@ -0,0 +1,113 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.http import request +from odoo.service import security + +logger = logging.getLogger(__name__) + + +class Users(models.Model): + _inherit = "res.users" + + def _get_partner_name(self, user_id): + return self.env["res.users"].browse(user_id).partner_id.name + + def _is_impersonate_user(self): + self.ensure_one() + return self.has_group("impersonate_login.group_impersonate_login") + + def impersonate_login(self): + if request: + if request.session.impersonate_from_uid: + if self.id == request.session.impersonate_from_uid: + return self.back_to_origin_login() + else: + raise UserError(_("You are already Logged as another user.")) + if self.id == request.session.uid: + raise UserError(_("It's you.")) + if request.env.user._is_impersonate_user(): + target_uid = self.id + request.session.impersonate_from_uid = self._uid + request.session.uid = target_uid + impersonate_log = ( + self.env["impersonate.log"] + .sudo() + .create( + { + "user_id": self._uid, + "impersonated_partner_id": self.env["res.users"] + .browse(target_uid) + .partner_id.id, + "date_start": fields.datetime.now(), + } + ) + ) + request.session.impersonate_log_id = impersonate_log.id + logger.info( + f"IMPERSONATE: {self._get_partner_name(self._uid)} " + f"Login as {self._get_partner_name(self.id)}" + ) + # invalidate session token cache as we've changed the uid + request.env["res.users"].clear_caches() + request.session.session_token = security.compute_session_token( + request.session, request.env + ) + + # reload the client; + return { + "type": "ir.actions.client", + "tag": "reload", + } + + @api.model + def action_impersonate_login(self): + if request: + from_uid = request.session.impersonate_from_uid + if not from_uid: + action = self.env["ir.actions.act_window"]._for_xml_id( + "base.action_res_users" + ) + action["views"] = [[self.env.ref("base.view_users_tree").id, "tree"]] + action["domain"] = [ + ("id", "!=", self.env.user.id), + ("share", "=", False), + ] + action["target"] = "new" + return action + + @api.model + def back_to_origin_login(self): + if request: + from_uid = request.session.impersonate_from_uid + if from_uid: + request.session.uid = from_uid + self.env["impersonate.log"].sudo().browse( + request.session.impersonate_log_id + ).write( + { + "date_end": fields.datetime.now(), + } + ) + # invalidate session token cache as we've changed the uid + request.env["res.users"].clear_caches() + request.session.impersonate_from_uid = False + request.session.impersonate_log_id = False + request.session.session_token = security.compute_session_token( + request.session, request.env + ) + logger.info( + f"IMPERSONATE: {self._get_partner_name(from_uid)} " + f"Logout as {self._get_partner_name(self._uid)}" + ) + + # reload the client; + return { + "type": "ir.actions.client", + "tag": "reload", + } diff --git a/impersonate_login/readme/CONTRIBUTORS.rst b/impersonate_login/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..56c4cb59e2 --- /dev/null +++ b/impersonate_login/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +- Kévin Roche +- [360ERP](https://www.360erp.com): + - Andrea Stirpe diff --git a/impersonate_login/readme/DESCRIPTION.rst b/impersonate_login/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..8df932e9d1 --- /dev/null +++ b/impersonate_login/readme/DESCRIPTION.rst @@ -0,0 +1,16 @@ +This module allows one user (for example, a member of the support team) +to log in as another user. The impersonation session can be exited by +clicking on the button "Back to Original User". + +To ensure that any abuse of this feature will not go unnoticed, the +following measures are in place: + +- In the chatter, it is displayed who is the user that is logged as + another user. +- Mails and messages are sent from the original user. +- Impersonated logins are logged and can be consulted through the + Settings -> Technical menu. +- + +There is an alternative module to allow logins as another user +(auth_admin_passkey), but it does not support these security mechanisms. diff --git a/impersonate_login/readme/USAGE.rst b/impersonate_login/readme/USAGE.rst new file mode 100644 index 0000000000..52029ce033 --- /dev/null +++ b/impersonate_login/readme/USAGE.rst @@ -0,0 +1,3 @@ +The impersonating user must belong to group "Impersonate Users". +- In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click "Switch Login" toi mpersonate another user. +- On the top-right corner, the button "Back to Original User" is displayed in case the current user is being impersonated. diff --git a/impersonate_login/security/group.xml b/impersonate_login/security/group.xml new file mode 100644 index 0000000000..e996175066 --- /dev/null +++ b/impersonate_login/security/group.xml @@ -0,0 +1,13 @@ + + + + + Impersonate Users + + + diff --git a/impersonate_login/security/ir.model.access.csv b/impersonate_login/security/ir.model.access.csv new file mode 100644 index 0000000000..3a5c10c53d --- /dev/null +++ b/impersonate_login/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_impersonate_log,impersonate logs,model_impersonate_log,base.group_user,1,1,0,0 diff --git a/impersonate_login/static/description/index.html b/impersonate_login/static/description/index.html new file mode 100644 index 0000000000..feda08e318 --- /dev/null +++ b/impersonate_login/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +Impersonate Login + + + +
+

Impersonate Login

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module allows one user (for example, a member of the support team) +to log in as another user. The impersonation session can be exited by +clicking on the button “Back to Original User”.

+

To ensure that any abuse of this feature will not go unnoticed, the +following measures are in place:

+
    +
  • In the chatter, it is displayed who is the user that is logged as +another user.
  • +
  • Mails and messages are sent from the original user.
  • +
  • Impersonated logins are logged and can be consulted through the +Settings -> Technical menu.
  • +
  • +
+

There is an alternative module to allow logins as another user +(auth_admin_passkey), but it does not support these security mechanisms.

+

Table of contents

+ +
+

Usage

+

The impersonating user must belong to group “Impersonate Users”. +- In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click “Switch Login” toi mpersonate another user. +- On the top-right corner, the button “Back to Original User” is displayed in case the current user is being impersonated.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

Kev-Roche

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/impersonate_login/static/src/css/navbar.scss b/impersonate_login/static/src/css/navbar.scss new file mode 100644 index 0000000000..556a38a5ee --- /dev/null +++ b/impersonate_login/static/src/css/navbar.scss @@ -0,0 +1,23 @@ +body.o_is_impersonated .o_menu_systray { + background: repeating-linear-gradient( + 135deg, + #32d804, + #32d804 10px, + #373435 10px, + #373435 20px + ); + border-bottom-left-radius: 20px; + + > li { + > a, + > label { + &:hover { + background-color: fade_out($o-navbar-inverse-link-hover-bg, 0.5); + } + } + } + + .show .dropdown-toggle { + background-color: fade_out($o-navbar-inverse-link-hover-bg, 0.5); + } +} diff --git a/impersonate_login/static/src/js/abstract_web_client.js b/impersonate_login/static/src/js/abstract_web_client.js new file mode 100644 index 0000000000..1c3ef6d444 --- /dev/null +++ b/impersonate_login/static/src/js/abstract_web_client.js @@ -0,0 +1,18 @@ +// Copyright 2024 Akretion (https://www.akretion.com). +// @author Kévin Roche +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +odoo.define("impersonate_login.AbstractWebClient", function (require) { + "use strict"; + + var AbstractWebClient = require("web.AbstractWebClient"); + var session = require("web.session"); + + AbstractWebClient.include({ + _onWebClientStarted: function () { + this._super.apply(this, arguments); + if (session.impersonate_from_uid) { + this.$el.addClass("o_is_impersonated"); + } + }, + }); +}); diff --git a/impersonate_login/static/src/js/user_menu.js b/impersonate_login/static/src/js/user_menu.js new file mode 100644 index 0000000000..ad3672ac1b --- /dev/null +++ b/impersonate_login/static/src/js/user_menu.js @@ -0,0 +1,66 @@ +// Copyright 2024 Akretion (https://www.akretion.com). +// @author Kévin Roche +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +odoo.define("impersonate_login.UserMenu", function (require) { + "use strict"; + + var UserMenu = require("web.UserMenu"); + var core = require("web.core"); + var session = require("web.session"); + var _t = core._t; + + UserMenu.include({ + start: function () { + this.toggleImpersonationLinks(); + return this._super.apply(this, arguments); + }, + + _onMenuImpersonate: function () { + var self = this; + this._rpc({ + model: "ir.model.data", + method: "xmlid_to_res_model_res_id", + args: ["impersonate_login.impersonate_res_users_tree"], + }).then(function (data) { + self.do_action({ + type: "ir.actions.act_window", + name: _t("Users"), + res_model: "res.users", + target: "new", + view_mode: "list", + views: [[data[1], "list"]], + domain: [["share", "=", false]], + }); + }); + }, + + _onMenuOrigin_login: function () { + var self = this; + return self + ._rpc({ + model: "res.users", + method: "back_to_origin_login", + args: [], + }) + .then(function () { + location.reload(true); + }); + }, + + toggleImpersonationLinks: function () { + var returnToLogin = this.$('[data-menu="origin_login"]'); + var impersonateLogin = this.$('[data-menu="impersonate"]'); + if (session.impersonate_from_uid) { + returnToLogin.removeClass("d-none"); + impersonateLogin.addClass("d-none"); + } else if (session.is_impersonate_user) { + returnToLogin.addClass("d-none"); + impersonateLogin.removeClass("d-none"); + } else { + returnToLogin.addClass("d-none"); + impersonateLogin.addClass("d-none"); + } + }, + }); +}); diff --git a/impersonate_login/static/src/xml/user_menu.xml b/impersonate_login/static/src/xml/user_menu.xml new file mode 100644 index 0000000000..bce12c050c --- /dev/null +++ b/impersonate_login/static/src/xml/user_menu.xml @@ -0,0 +1,22 @@ + + + + + + 🔙 To my Login + 🔄 Switch Login + + + diff --git a/impersonate_login/tests/__init__.py b/impersonate_login/tests/__init__.py new file mode 100644 index 0000000000..d2f02c982b --- /dev/null +++ b/impersonate_login/tests/__init__.py @@ -0,0 +1 @@ +from . import test_impersonate_login diff --git a/impersonate_login/tests/test_impersonate_login.py b/impersonate_login/tests/test_impersonate_login.py new file mode 100644 index 0000000000..41b5e04062 --- /dev/null +++ b/impersonate_login/tests/test_impersonate_login.py @@ -0,0 +1,263 @@ +# Copyright 2024 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +import json +from uuid import uuid4 + +from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger + + +@tagged("post_install", "-at_install") +class TestImpersonateLogin(HttpCase): + def setUp(self): + super().setUp() + self.admin_user = self.env.ref("base.user_admin") + self.demo_user = self.env.ref("base.user_demo") + + def _impersonate_user(self, user): + response = self.url_open( + "/web/dataset/call_button", + data=json.dumps( + { + "params": { + "model": "res.users", + "method": "impersonate_login", + "args": [user.id], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _action_impersonate_login(self): + response = self.url_open( + "/web/dataset/call_button", + data=json.dumps( + { + "params": { + "model": "res.users", + "method": "action_impersonate_login", + "args": [], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _get_session_info(self): + response = self.url_open( + "/web/session/get_session_info", + data=json.dumps(dict(jsonrpc="2.0", method="call", id=str(uuid4()))), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def test_01_admin_impersonates_user_demo(self): + """Admin user impersonates Demo user""" + # Login as admin + self.authenticate(user="admin", password="admin") + self.assertEqual(self.session.uid, self.admin_user.id) + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.admin_user.login) + self.assertTrue(result["is_system"]) + self.assertTrue(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + # Switch Login button + data = self._action_impersonate_login() + result = data["result"] + self.assertEqual(result["target"], "new") + + # Impersonate demo user + data = self._impersonate_user(self.demo_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.demo_user.login) + self.assertFalse(result["is_system"]) + self.assertFalse(result["is_admin"]) + self.assertFalse(result["is_impersonate_user"]) + self.assertEqual(result["impersonate_from_uid"], self.admin_user.id) + + # Check impersonate log + log1 = self.env["impersonate.log"].search([], order="id desc", limit=1) + self.assertTrue(log1.date_start) + self.assertFalse(log1.date_end) + + # Impersonate demo user again: error + with mute_logger("odoo.http"): + data = self._impersonate_user(self.demo_user) + result = data["error"] + self.assertEqual( + result["data"]["message"], "You are already Logged as another user." + ) + + # Back to original user + data = self._impersonate_user(self.admin_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.admin_user.login) + self.assertTrue(result["is_system"]) + self.assertTrue(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + # Check impersonate log + log2 = self.env["impersonate.log"].search([], order="id desc", limit=1) + # Refresh the log1 after the attribute date_end is updated + log1.refresh() + self.assertEqual(log1, log2) + self.assertTrue(log1.date_start) + self.assertTrue(log1.date_end) + + def test_02_user_demo_impersonates_admin(self): + """Demo user impersonates Admin user""" + # Login as demo user + self.authenticate(user="demo", password="demo") + self.assertEqual(self.session.uid, self.demo_user.id) + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertFalse(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + # Impersonate demo user: is already current user + self.demo_user.groups_id += self.env.ref( + "impersonate_login.group_impersonate_login" + ) + with mute_logger("odoo.http"): + data = self._impersonate_user(self.demo_user) + result = data["error"] + self.assertEqual(result["data"]["message"], "It's you.") + + # Impersonate admin user + data = self._impersonate_user(self.admin_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.admin_user.login) + self.assertTrue(result["is_system"]) + self.assertTrue(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertEqual(result["impersonate_from_uid"], self.demo_user.id) + + # Impersonate admin user again: error + with mute_logger("odoo.http"): + data = self._impersonate_user(self.admin_user) + result = data["error"] + self.assertEqual( + result["data"]["message"], "You are already Logged as another user." + ) + + # Back to original user + data = self._impersonate_user(self.demo_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.demo_user.login) + self.assertFalse(result["is_system"]) + self.assertFalse(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + def test_03_create_uid(self): + """Check the create_uid of records created + during an impersonated session""" + # Login as admin + self.authenticate(user="admin", password="admin") + + # Impersonate demo user and create a contact + self._impersonate_user(self.demo_user) + + response = self.url_open( + "/web/dataset/call_kw/res.partner/create", + data=json.dumps( + { + "params": { + "model": "res.partner", + "method": "create", + "args": [ + { + "name": "Contact123", + }, + ], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + contact_id = data["result"] + + contact = self.env["res.partner"].browse(contact_id) + self.assertEqual(contact.name, "Contact123") + self.assertEqual(contact.create_uid, self.admin_user) + + def test_04_write_uid(self): + """Check the write_uid of records created + during an impersonated session""" + # Login as admin + self.authenticate(user="admin", password="admin") + + # Create a contact + contact = self.env["res.partner"].create({"name": "ContactABC"}) + + # Impersonate demo user and modify a contact + self._impersonate_user(self.demo_user) + + response = self.url_open( + "/web/dataset/call_kw/res.partner/write", + data=json.dumps( + { + "params": { + "model": "res.partner", + "method": "write", + "args": [ + [contact.id], + { + "ref": "abc", + }, + ], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + result = data["result"] + + # Refresh contact to reflect changes in the database + self.assertEqual(result, True) + contact.invalidate_cache() + self.assertEqual(contact.ref, "abc") + self.assertEqual(contact.write_uid, self.admin_user) diff --git a/impersonate_login/views/assets.xml b/impersonate_login/views/assets.xml new file mode 100644 index 0000000000..cf15b77905 --- /dev/null +++ b/impersonate_login/views/assets.xml @@ -0,0 +1,24 @@ + + +