Skip to main content
iVentureTeam

portal_wizard_user_one2many

The Grant Access dialog is one of the few places in Odoo where a double click can send two invitation emails. portal_wizard_user_one2many is the twenty-line widget that stops it.

Siddharth JambukiyaSiddharth JambukiyaOdoo Techno-Functional Consultant
August 27, 2026Updated August 27, 20266 min read
Technical nameportal_wizard_user_one2many
Field typesone2many
Viewsform, always with an inline list subview
Moduleportal, installed by default with almost every Odoo app
Used in core1 occurrence, the Grant Portal Access wizard in portal
VersionsOdoo 20.0, Odoo 19.0, Odoo 18.0, Odoo 17.0, Odoo 16.0
No-code setupNo. It is a view XML widget and only makes sense on a wizard that runs long server actions per row
Alternativesone2many, many2many, list_activity

What the portal wizard list field does

When you select contacts and choose Grant portal access, Odoo opens a wizard listing those contacts with a status icon and an action button on each row. Every button on that list triggers a server method that creates a user, sends an invitation, or revokes access. Those methods take a moment, and the buttons stay clickable while they run.

That is the problem this widget exists for. It is a one2many widget that changes exactly one thing about the embedded list: the controller behind it. The replacement controller keeps a flag while a button's action is in flight and refuses any further button click until it finishes.

It also blocks one button name unconditionally. The three little icons in the email column, the green check, the red cross and the crossed-out user, are declared in the view as buttons calling action_refresh_modal. The controller returns false for that name every time, so the call is never made. They exist to be visible, not to be pressed.

What this means for your team

The failure this prevents is embarrassing rather than catastrophic: a customer receives two identical portal invitations, or an access grant runs twice and the admin cannot tell whether it worked. On a wizard where each row is a real person receiving a real email, that is worth twenty lines of code.

There is a wider lesson in it for custom work. Any wizard where a row-level button sends mail, calls an external service or creates users has the same exposure, and Odoo's default list controller does nothing about it. If your implementation has grown that kind of screen, this widget is the pattern to copy: a small controller subclass, one boolean, and buttons that stop being able to double-fire.

The status icons are worth explaining to admins too. Users assume a red cross next to an email is a button that will fix something. It is a diagnosis: the address is invalid, or already belongs to another user. The fix is to edit the email inline, which revalidates the row.

Supported options in Odoo 19

The widget declares no options of its own, and neither does the x2many descriptor it spreads. That is not the same as ignoring options: everything in the options dictionary is passed through to the embedded list as CRUD settings. The controls people actually use on this list are the attributes below, verified in the Odoo 19.0 source and in the core wizard view.

OptionTypeWhat it does
editablestring (list attribute)Set on the embedded <list> element, not on the widget. The core wizard uses bottom so the email column can be corrected inline.
createboolean (list attribute)Set on the embedded <list> element. Core sets it to false: rows come from the contacts you selected, not from typing.(default: true)
deleteboolean (list attribute)Set on the embedded <list> element. Core sets it to false for the same reason.(default: true)

Nothing here changes the guard. The re-entrancy protection and the blocked status icons are hardcoded in the controller subclass, so no XML option turns them off. If you need action_refresh_modal to actually run, it has to be renamed in the view or the controller has to be patched in JavaScript.

Working examples

The core wizard, trimmed

<field name="user_ids" widget="portal_wizard_user_one2many">
  <list editable="bottom" create="false" delete="false">
    <field name="partner_id" force_save="1"/>
    <field name="email" readonly="is_internal"/>
    <button name="action_grant_access" type="object"
            string="Grant Access"/>
  </list>
</field>

create="false" and delete="false" matter: the rows come from the selected contacts, not from the user.

Status icons as declared in core

<button name="action_refresh_modal" type="object"
        icon="fa-check text-success"
        invisible="email_state != 'ok'"
        title="Valid Email Address"/>

Declared as a button, blocked by the controller. Three of these cover the ok, invalid and already-taken states.

Reusing the pattern on your own wizard

<field name="line_ids" widget="portal_wizard_user_one2many">
  <list editable="bottom">
    <field name="email"/>
    <button name="action_send" type="object" string="Send"/>
  </list>
</field>

Legal and it works, since nothing in the widget is portal-specific. Just remember the action_refresh_modal name stays blocked wherever you use it.

Twenty lines, two hooks, one flag

The whole widget is a component subclass swapping one entry in X2ManyField.components: Controller becomes PortalWizardUserListController. That controller overrides two hooks of the standard list controller.

beforeExecuteActionButton runs before any row button executes. It returns false if the button is named action_refresh_modal or if a portal action is already running, otherwise it sets the in-flight flag and calls the base implementation. afterExecuteActionButton clears the flag and, unlike the base version, does not chain to super.

Returning false there is not a soft signal. In Odoo's view-button hook, a falsy result from the before-hook aborts the click completely, before the action is dispatched. That is what makes the status icons inert, and it has a side effect worth knowing: the base beforeExecuteActionButton is what saves the row you are currently editing, so clicking a status icon skips that save too.

One rough edge is visible in the ordering. The flag is set to true before the base implementation is awaited, and the base implementation saves the edited row. If that save is rejected, for example because a required field is empty, the promise rejects and the after-hook never runs, so the flag stays set and every button in the dialog goes quiet. Closing and reopening the wizard clears it. Everything else, including a server error inside the action itself, is handled correctly, because the view-button hook calls the after-hook even when the action throws.

Version compatibility

VersionStatusNotes
Odoo 20.0In developmentNot released. Byte-identical to Odoo 19 on the development branch.
Odoo 19.0VerifiedVerified against the shipped source. Same controller and same guard.
Odoo 18.0VerifiedIdentical behavior. No XML change needed.
Odoo 17.0VerifiedIdentical behavior. This is where the registration became a descriptor object.
Odoo 16.0VerifiedSame guard, but the component class was registered directly instead of a descriptor.

Upgrade note. No XML change has ever been needed for this widget. Between Odoo 16 and Odoo 17 the registration switched from registering the component class directly to registering a descriptor object, which matters only to custom JavaScript that imported it. The controller itself has not changed since Odoo 16.

What is changing in Odoo 20

Odoo 20 is expected at Odoo Experience in Brussels, 24 to 26 September 2026. The development branch is unstable and can still change before release; we re-verify this page after the release.

Nothing changes. Both the widget file and the controller file are byte-identical between Odoo 19.0 and the development branch, which is rare in a release cycle that rewrote most field widgets onto the new Owl props syntax. Views using it need no attention during an Odoo 20 upgrade.

Common problems and fixes

SymptomCause and fix
Clicking the green check or red cross does nothingIntended. The controller blocks every button named action_refresh_modal. Treat them as status indicators. To refresh the state, edit the email in the row.
All buttons in the dialog stop respondingThe in-flight flag was set and then a row save was rejected, so it was never cleared. Close and reopen the wizard. Fix the invalid row first, then run the action.
The email I typed was not savedYou clicked a status icon, which is aborted before the standard row save runs. Click elsewhere in the list, or press the real action button, which does save the row first.
Two invitations were sent anywayThe two clicks landed in different dialog sessions, or a custom view replaced the widget with a plain one2many. Confirm the field still carries widget="portal_wizard_user_one2many" in the inherited view.
Users can add rows to the listAn inherited view dropped create="false" from the embedded list. Restore create="false" and delete="false" on the <list> element.
Nothing happens on Grant Access for an internal userInternal users are excluded; core renders a disabled button with an explanatory tooltip on those rows. Expected. Internal users already have portal-level access.

Portal wizard list field vs the alternatives

WidgetBest forKey difference
portal_wizard_user_one2manyWizard lists whose row buttons run slow, irreversible server actionsSwaps the list controller to drop repeat clicks and to block the refresh-status button entirely
one2manyAny ordinary embedded list of child recordsStandard controller, so nothing prevents a button from being clicked twice
many2manyLinking existing records rather than editing owned onesAdd and remove links instead of creating and deleting rows
list_activityShowing the next action per rowRenders activity state in a column, unrelated to button safety

Choose by what the list has to survive. A plain one2many is right whenever the rows only hold data. This widget earns its place the moment a row button triggers something you cannot take back, and its controller is the piece worth copying into your own wizards rather than the widget name itself.

Frequently asked questions

What does portal_wizard_user_one2many actually change?+
Only the controller behind the embedded list. It keeps a flag while a row button's action runs and refuses further button clicks until it finishes, and it blocks any button named action_refresh_modal outright.
Why are the email status icons not clickable?+
They are declared as buttons calling action_refresh_modal, and the controller returns false for that name every time. In Odoo's button hook a false result aborts the click, so the server method is never reached. They are diagnostics for the email address.
Can I use this widget on my own wizard?+
Yes. Nothing in it is portal-specific, so any one2many with slow row buttons benefits. Keep in mind that the action_refresh_modal name stays blocked wherever the widget is used.
Does it support any options?+
None of its own. It spreads the x2many descriptor, which declares no options either and simply forwards the whole options dictionary to the embedded list as CRUD settings. The knobs that matter are attributes on the inner <list> element.
Will it change in Odoo 20?+
Not as far as the development branch shows. Both the widget and its controller are byte-identical to Odoo 19, which is unusual in a cycle that rewrote most field widgets.

Do your wizards survive an impatient double click?

Row buttons that send mail, create users or call an external API are the quiet risk in most custom Odoo screens, and the default list controller does nothing to protect them. We review and harden that kind of workflow as part of Odoo customization work on 16 through 19.

Book a free consultation

How this page was produced

The controller behavior, the blocked button name and the abort semantics were read from portal_wizard_user_one2many.js, portal_wizard_user_list_controller.js and the shared view_button_hook.js on the Odoo 19.0 branch, together with the wizard view in portal/wizard/portal_wizard_views.xml. Version history comes from the same files on 16.0, 17.0 and 18.0, and the Odoo 20 statement from a byte comparison against the public development branch. Spotted an error? Tell us and we will correct the page.