1 Commits

Author SHA1 Message Date
4d739af30a [ADD]partner_create_internal_user 2026-07-01 16:57:56 +02:00
8 changed files with 816 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
============================
Partner Create Internal User
============================
This module adds a **Grant internal user access** action on contacts
(``res.partner``), mirroring the standard *Grant portal access* wizard
provided by the ``portal`` module, but creating an **internal user**
(``base.group_user``) instead of a portal user.
**Table of contents**
.. contents::
:local:
Features
========
Grant Internal User Access
--------------------------
A new server action *Grant internal user access* is available from the
contact list and form views (in the *Action* menu). It opens a wizard listing
the selected contacts (and their child contacts), where you can, for each line:
* **Grant Access**: create (or reuse) a user, make it an internal user and
send the standard "set your password" invitation email.
* **Revoke Access**: archive the internal user.
* **Re-Invite**: send the invitation email again.
Email Validation
----------------
For each contact, the email address is validated and the wizard detects
whether the email is already used by another user, preventing duplicates.
Invalid or already-registered emails block the access grant.
How It Works
============
The wizard is built on top of the same logic as the standard ``portal.wizard``:
1. **Create**: when the contact has no user yet, a user is created from the
default user template (the ``base.template_portal_user_id`` system
parameter), in the same company as the contact.
2. **Promote**: the user is added to the internal users group
(``base.group_user``) and removed from the portal/public groups, so a
contact previously linked to a portal user is upgraded to internal.
3. **Invite**: an invitation email is sent using the standard
``auth_signup.set_password_email`` template (via ``action_reset_password``
with the ``create_user`` context), letting the user define their password.
Dependencies
============
This module depends on:
* ``auth_signup``: provides the user creation from template, the signup token
mechanism and the "set password" invitation email.
Installation
============
Use the standard Odoo module installation procedure to install
``partner_create_internal_user``.
Usage
=====
1. Open a contact (or select several contacts in the list view).
2. Use *Action > Grant internal user access*.
3. Fix the email addresses if needed, then click *Grant Access*.
Configuration
=============
Creating internal users is restricted to users belonging to the
*Administration / Access Rights* group (``base.group_erp_manager``).
The new users are created from the template user configured through the
``base.template_portal_user_id`` system parameter and then moved to the
internal users group.
Bug Tracker
===========
Bugs are tracked on `Elabore Git Issues <https://git.elabore.coop/Elabore/partner-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smash it by providing a detailed and welcomed
feedback.
Credits
=======
Authors
-------
* `Elabore <https://elabore.coop>`_
Maintainer
----------
.. image:: https://elabore.coop/logo.png
:alt: Elabore
:target: https://elabore.coop
This module is maintained by Elabore.

View File

@@ -0,0 +1 @@
from . import wizard

View File

@@ -0,0 +1,25 @@
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "partner_create_internal_user",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Elabore",
"license": "AGPL-3",
"category": "Tools",
"summary": "Grant internal user access to a contact, "
"the same way as 'Grant portal access' but creating an internal user.",
"depends": [
"auth_signup",
"mail",
],
"data": [
"security/ir.model.access.csv",
"wizard/internal_user_wizard_views.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View File

@@ -0,0 +1,238 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * partner_create_internal_user
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-04 10:08+0000\n"
"PO-Revision-Date: 2026-06-04 10:08+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: partner_create_internal_user
#: model:ir.model.fields.selection,name:partner_create_internal_user.selection__internal_user_wizard_user__email_state__exist
msgid "Already Registered"
msgstr "Déjà enregistré"
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "Cannot send the invitation: the user of %s has no email address."
msgstr "Impossible d'envoyer l'invitation : l'utilisateur %s n'a pas d'adresse e-mail. Impossible d'envoyer l'invitation : l'utilisateur %s n'a pas d'adresse e-mail."
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "Back office access granted"
msgstr "Accès au back office accordé"
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "Back office access revoked"
msgstr "Accès au back office révoqué"
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Close"
msgstr "Fermer"
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__partner_id
msgid "Contact"
msgstr ""
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Contacts"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__create_uid
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__create_uid
msgid "Created by"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__create_date
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__create_date
msgid "Created on"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__display_name
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__display_name
msgid "Display Name"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__email
msgid "Email"
msgstr ""
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Email Address already taken by another user"
msgstr "Cette adresse e-mail est déjà utilisée par un autre utilisateur"
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Grant Access"
msgstr "Accorder l'accès"
#. module: partner_create_internal_user
#: model:ir.model,name:partner_create_internal_user.model_internal_user_wizard
msgid "Grant Internal User Access"
msgstr "Créer un utilisateur interne"
#. module: partner_create_internal_user
#: model:ir.actions.server,name:partner_create_internal_user.partner_internal_user_wizard_action_create_and_open
msgid "Grant internal user access"
msgstr "Créer un utilisateur interne"
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__id
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__id
msgid "ID"
msgstr ""
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Internal User Access Management"
msgstr "Gestion des accès des utilisateurs internes"
#. module: partner_create_internal_user
#: model:ir.model,name:partner_create_internal_user.model_internal_user_wizard_user
msgid "Internal User Config"
msgstr "Configuration utilisateur interne"
#. module: partner_create_internal_user
#: model:ir.model.fields.selection,name:partner_create_internal_user.selection__internal_user_wizard_user__email_state__ko
msgid "Invalid"
msgstr "Invalide"
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Invalid Email Address"
msgstr "Email adresse invalide"
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__is_internal
msgid "Is Internal"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__is_portal
msgid "Is Portal"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__write_uid
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__write_uid
msgid "Last Updated by"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__write_date
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__write_date
msgid "Last Updated on"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__login_date
msgid "Latest Authentication"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__partner_ids
msgid "Partners"
msgstr "Contacts"
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Re-Invite"
msgstr "Ré-inviter"
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Revoke Access"
msgstr "Révoquer l'accès"
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid ""
"Select which contacts should become internal users in the list below.\n"
" The email address of each selected contact must be valid and unique.\n"
" If necessary, you can fix any contact's email address directly in the list."
msgstr ""
"Sélectionnez dans la liste ci-dessous les contacts qui doivent devenir des utilisateurs internes.\n"
" L'adresse e-mail de chaque contact sélectionné doit être valide et unique.\n"
" Si nécessaire, vous pouvez modifier l'adresse e-mail d'un contact directement dans la liste."
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__email_state
msgid "Status"
msgstr "Statut"
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "The contact \"%s\" does not have a valid email."
msgstr "L'adresse email du contact \"%s\" n'est pas valide."
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "The contact \"%s\" has the same email as an existing user."
msgstr "Le contact \"%s\" a la même adresse email qu'un utilisateur existant."
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "The partner \"%s\" already has internal user access."
msgstr "Le contact \"%s\" a déjà un utilisateur interne associé."
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "The partner \"%s\" has no internal user access."
msgstr "Le contact \"%s\" n'a pas d'utilisateur interne associé."
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__user_id
msgid "User"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__user_ids
msgid "Users"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields.selection,name:partner_create_internal_user.selection__internal_user_wizard_user__email_state__ok
msgid "Valid"
msgstr ""
#. module: partner_create_internal_user
#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view
msgid "Valid Email Address"
msgstr ""
#. module: partner_create_internal_user
#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__wizard_id
msgid "Wizard"
msgstr ""
#. module: partner_create_internal_user
#. odoo-python
#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0
msgid "You should first grant internal user access to the partner \"%s\"."
msgstr ""

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_internal_user_wizard,access.internal.user.wizard,model_internal_user_wizard,base.group_erp_manager,1,1,1,0
access_internal_user_wizard_user,access.internal.user.wizard.user,model_internal_user_wizard_user,base.group_erp_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_internal_user_wizard access.internal.user.wizard model_internal_user_wizard base.group_erp_manager 1 1 1 0
3 access_internal_user_wizard_user access.internal.user.wizard.user model_internal_user_wizard_user base.group_erp_manager 1 1 1 0

View File

@@ -0,0 +1 @@
from . import internal_user_wizard

View File

@@ -0,0 +1,390 @@
# Copyright 2026 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import Command, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import email_normalize
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
class InternalUserWizard(models.TransientModel):
"""A wizard to manage the creation of internal users from contacts.
It mirrors the standard ``portal.wizard`` ("Grant portal access") but
creates internal users (``base.group_user``) instead of portal users.
"""
_name = "internal.user.wizard"
_description = "Grant Internal User Access"
def _default_partner_ids(self):
partner_ids = self.env.context.get(
"default_partner_ids", []
) or self.env.context.get("active_ids", [])
contact_ids = set()
for partner in self.env["res.partner"].sudo().browse(partner_ids):
contact_partners = (
partner.child_ids.filtered(lambda p: p.type in ("contact", "other"))
| partner
)
contact_ids |= set(contact_partners.ids)
return [Command.link(contact_id) for contact_id in contact_ids]
partner_ids = fields.Many2many(
"res.partner", string="Partners", default=_default_partner_ids
)
user_ids = fields.One2many(
"internal.user.wizard.user",
"wizard_id",
string="Users",
compute="_compute_user_ids",
store=True,
readonly=False,
)
@api.depends("partner_ids")
def _compute_user_ids(self):
for wizard in self:
wizard.user_ids = [
Command.create(
{
"partner_id": partner.id,
"email": partner.email,
}
)
for partner in wizard.partner_ids
]
@api.model
def action_open_wizard(self):
"""Create an "internal.user.wizard" and open its form view.
A server action is needed so the one2many ``user_ids`` records exist
(and thus have an id) before the form is displayed, otherwise the
per-line buttons would be disabled.
"""
wizard = self.create({})
return wizard._action_open_modal()
def _action_open_modal(self):
"""Allow to keep the wizard modal open after executing an action."""
return {
"name": _("Internal User Access Management"),
"type": "ir.actions.act_window",
"res_model": "internal.user.wizard",
"view_mode": "form",
"res_id": self.id,
"target": "new",
}
class InternalUserWizardUser(models.TransientModel):
"""A model to configure the users created by the internal user wizard."""
_name = "internal.user.wizard.user"
_description = "Internal User Config"
wizard_id = fields.Many2one(
"internal.user.wizard",
string="Wizard",
required=True,
ondelete="cascade",
)
partner_id = fields.Many2one(
"res.partner",
string="Contact",
required=True,
readonly=True,
ondelete="cascade",
)
email = fields.Char("Email")
user_id = fields.Many2one(
"res.users",
string="User",
compute="_compute_user_id",
compute_sudo=True,
)
login_date = fields.Datetime(
related="user_id.login_date", string="Latest Authentication"
)
is_internal = fields.Boolean("Is Internal", compute="_compute_group_details")
is_portal = fields.Boolean("Is Portal", compute="_compute_group_details")
email_state = fields.Selection(
[
("ok", "Valid"),
("ko", "Invalid"),
("exist", "Already Registered"),
],
string="Status",
compute="_compute_email_state",
default="ok",
)
@api.depends("email")
def _compute_email_state(self):
users_with_email = self.filtered(lambda user: email_normalize(user.email))
(self - users_with_email).email_state = "ko"
existing_users = (
self.env["res.users"]
.with_context(active_test=False)
.sudo()
.search_read(
self._get_similar_users_domain(users_with_email),
self._get_similar_users_fields(),
)
)
for wizard_user in users_with_email:
if next(
(
user
for user in existing_users
if self._is_similar_than_user(user, wizard_user)
),
None,
):
wizard_user.email_state = "exist"
else:
wizard_user.email_state = "ok"
@api.depends("partner_id")
def _compute_user_id(self):
for wizard_user in self:
user = wizard_user.partner_id.with_context(active_test=False).user_ids
wizard_user.user_id = user[0] if user else False
@api.depends("user_id", "user_id.active", "user_id.groups_id")
def _compute_group_details(self):
for wizard_user in self:
user = wizard_user.user_id
if user and user.active and user._is_internal():
wizard_user.is_internal = True
wizard_user.is_portal = False
elif user and user.active and user._is_portal():
wizard_user.is_internal = False
wizard_user.is_portal = True
else:
wizard_user.is_internal = False
wizard_user.is_portal = False
def action_grant_access(self):
"""Grant internal user access to the partner.
If the partner has no linked user, a new one is created in the same
company as the partner (or in the current company if not set) and added
to the internal users group. An invitation email is then sent so the
user can set their password.
"""
self.ensure_one()
self._assert_user_email_uniqueness()
if self.is_internal:
raise UserError(
_(
'The partner "%s" already has internal user access.',
self.partner_id.name,
)
)
group_user = self.env.ref("base.group_user")
group_portal = self.env.ref("base.group_portal")
group_public = self.env.ref("base.group_public")
self._update_partner_email()
user_sudo = self.user_id.sudo()
if not user_sudo:
# create a user if necessary and make sure it is internal
company = self.partner_id.company_id or self.env.company
user_sudo = self.sudo().with_company(company.id)._create_user()
# make sure the (possibly reused portal) user becomes internal.
# ``mail_notrack`` prevents ``res.users.write`` (mail module) from
# logging a spurious "Portal Access Revoked" note when the portal group
# is removed here.
user_sudo.with_context(mail_notrack=True).write(
{
"active": True,
"groups_id": [
Command.link(group_user.id),
Command.unlink(group_portal.id),
Command.unlink(group_public.id),
],
}
)
self._log_internal_access_change(True)
self._send_invitation_email(user_sudo)
return self.action_refresh_modal()
def action_revoke_access(self):
"""Archive the internal user of the partner."""
self.ensure_one()
if not self.is_internal:
raise UserError(
_(
'The partner "%s" has no internal user access.',
self.partner_id.name,
)
)
self._update_partner_email()
# Remove the sign up token, so it can not be used
self.partner_id.sudo().signup_type = None
user_sudo = self.user_id.sudo()
if user_sudo and user_sudo._is_internal():
user_sudo.write({"active": False})
self._log_internal_access_change(False)
return self.action_refresh_modal()
def action_invite_again(self):
"""Re-send the invitation email to the partner."""
self.ensure_one()
self._assert_user_email_uniqueness()
if not self.is_internal:
raise UserError(
_(
'You should first grant internal user access to the '
'partner "%s".',
self.partner_id.name,
)
)
self._update_partner_email()
self._send_invitation_email(self.user_id.sudo())
return self.action_refresh_modal()
def action_refresh_modal(self):
"""Refresh the wizard modal and keep it open."""
return self.wizard_id._action_open_modal()
def _get_internal_access_update_body(self, access_granted):
"""Build the chatter note logged on the partner for an access change.
It mirrors ``res.users._get_portal_access_update_body`` from the
``mail`` module, which logs "Portal Access Granted/Revoked", but for
back office (internal user) access.
"""
body = (
_("Back office access granted")
if access_granted
else _("Back office access revoked")
)
if self.partner_id.email:
return "%s (%s)" % (body, self.partner_id.email)
return body
def _log_internal_access_change(self, access_granted):
"""Log a back office access change as a note in the partner chatter."""
self.partner_id.message_post(
body=self._get_internal_access_update_body(access_granted),
message_type="notification",
subtype_xmlid="mail.mt_note",
)
def _create_user(self):
"""Create a new internal user for ``wizard_user.partner_id``.
:returns: record of res.users
"""
# ``mail_notrack`` prevents ``res.users.create`` (mail module) from
# logging a spurious "Portal Access Granted" note: the template user is
# a portal user, so the freshly created user is momentarily a portal
# user before being turned into an internal one.
return self.env["res.users"].with_context(
no_reset_password=True, mail_notrack=True
)._create_user_from_template(
{
"email": email_normalize(self.email),
"login": email_normalize(self.email),
"partner_id": self.partner_id.id,
"company_id": self.env.company.id,
"company_ids": [Command.set(self.env.company.ids)],
"lang": self.env.lang,
}
)
def _send_invitation_email(self, user_sudo):
"""Send the "set your password" invitation email to a new user.
The email is sent on a best-effort basis: if the mail cannot be
delivered (e.g. no outgoing mail server is configured), the internal
user has already been created and must not be rolled back, so the
delivery error is only logged. ``action_reset_password`` runs the
actual send in its own savepoint, so catching the error here keeps the
user creation intact.
"""
if not user_sudo.email:
raise UserError(
_(
"Cannot send the invitation: the user of %s has no email "
"address.",
self.partner_id.name,
)
)
# ``create_user`` context makes Odoo use the "account created" template
# (auth_signup.set_password_email) instead of a plain password reset.
try:
user_sudo.with_context(create_user=True).action_reset_password()
except UserError as error:
_logger.warning(
"Internal user %s created but the invitation email could not "
"be sent: %s",
user_sudo.login,
error,
)
return False
return True
def _assert_user_email_uniqueness(self):
"""Check that the email can be used to create a new user."""
self.ensure_one()
if self.email_state == "ko":
raise UserError(
_(
'The contact "%s" does not have a valid email.',
self.partner_id.name,
)
)
if self.email_state == "exist":
raise UserError(
_(
'The contact "%s" has the same email as an existing user.',
self.partner_id.name,
)
)
def _update_partner_email(self):
"""Update the partner email if a new valid one was introduced."""
email_normalized = email_normalize(self.email)
if self.email_state == "ok" and (
email_normalize(self.partner_id.email) != email_normalized
):
self.partner_id.write({"email": email_normalized})
def _get_similar_users_domain(self, users_with_email):
normalized_emails = [
email_normalize(wizard_user.email) for wizard_user in users_with_email
]
return [("login", "in", normalized_emails)]
def _get_similar_users_fields(self):
return ["id", "login"]
def _is_similar_than_user(self, user, wizard_user):
"""Check whether ``user`` is a distinct user sharing the email."""
return user["login"] == email_normalize(wizard_user.email) and (
user["id"] != wizard_user.user_id.id
)

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- wizard action on res.partner -->
<record id="partner_internal_user_wizard_action_create_and_open" model="ir.actions.server">
<field name="name">Grant internal user access</field>
<field name="model_id" ref="model_internal_user_wizard"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="code">action = model.action_open_wizard()</field>
</record>
<!-- wizard view -->
<record id="internal_user_wizard_view" model="ir.ui.view">
<field name="name">Grant internal user access</field>
<field name="model">internal.user.wizard</field>
<field name="arch" type="xml">
<form string="Internal User Access Management">
<div class="mb-3">
Select which contacts should become internal users in the list below.
The email address of each selected contact must be valid and unique.
If necessary, you can fix any contact's email address directly in the list.
</div>
<field name="user_ids">
<list string="Contacts" editable="bottom" create="false" delete="false">
<field name="partner_id" force_save="1"/>
<field name="email" readonly="is_internal"/>
<field name="email_state" column_invisible="True"/>
<button name="action_refresh_modal" type="object" icon="fa-check text-success"
invisible="email_state != 'ok'" title="Valid Email Address"/>
<button name="action_refresh_modal" type="object" icon="fa-times text-danger"
invisible="email_state != 'ko'" title="Invalid Email Address"/>
<button name="action_refresh_modal" type="object" icon="fa-user-times text-danger"
invisible="email_state != 'exist'" title="Email Address already taken by another user"/>
<field name="login_date"/>
<field name="is_internal" column_invisible="True"/>
<field name="is_portal" column_invisible="True"/>
<button string="Grant Access" name="action_grant_access" type="object" class="btn-secondary"
invisible="is_internal or email_state != 'ok'"/>
<button string="Revoke Access" name="action_revoke_access" type="object" class="btn-secondary"
invisible="not is_internal"/>
<button string="Re-Invite" name="action_invite_again" type="object" class="btn-secondary"
invisible="not is_internal or email_state != 'ok'"/>
</list>
</field>
<footer>
<button string="Close" class="btn-primary" special="save" data-hotkey="v"/>
</footer>
</form>
</field>
</record>
</odoo>