EVERJUST Client Portal — Agent Skill
Operate the client portal: the /my/* self-service frontend where a customer logs in
(or follows a signed link) to see their own records — quotes, invoices, sales orders,
projects, tasks, tickets, signatures. Two distinct capabilities, don't conflate them:
- Grant portal access — turn a contact (
res.partner) into a login user with
share=True in base.group_portal, and email them a set-password invite. They then
sign in at https://<tenant>.everjust.app/my and browse everything linked to their
partner. This is portal.wizard → portal.wizard.user.action_grant_access.
- Share one record — send a specific partner a link to one backend record, gated by
an
access_token (bypasses their rights for that record only) or a signup token — with
no login required. This is portal.share wizard, or directly
record.get_portal_url() / record._get_share_url(...) on any portal.mixin model.
You drive all of this through the platform's Odoo MCP (env[...], search, create,
call) — see [[everjust-agent-mcp]] for opening the session against the right tenant. For
the tenancy model and what a workspace is for, see [[everjust-platform]].
When to use this skill
- Invite a customer to the portal — "give jane@acme.com portal access", "let this
contact log in and see their invoices/projects", "onboard the client to their portal".
- Re-invite / revoke — resend the set-password mail, or archive a portal login.
- Share a single record by link — "send this invoice/quote/SO/task to the customer",
"generate a shareable link for record X" — with or without them having a login.
- Explain portal visibility — "why can this portal user see the invoice but not the
contact form", "what will they see when they log in".
- Audit portal users — list
res.users with share=True, see who has access to what.
When NOT to use this skill — stop if the task is really:
- Provisioning an INTERNAL (staff) user — an employee who works in
/odoo, not a
customer on /my. That is base.group_user (internal), not group_portal, and is
platform/admin work. A portal user has share=True; an internal user has share=False.
- The public marketing website (
website_connectdomain, website_tcsw, the /
homepage) — that is anonymous QWeb pages, not the authenticated /my portal. See the
website tools in [[everjust-agent-mcp]] / [[everjust-platform]].
- The everjust.mail webmail (
everjust.mail.*) — a separate product; sending the
invite email uses Odoo's own mail templates, not that stack. See [[everjust-mail-ops]].
- e-signature request flow —
sign.request has its own signer-invite path; portal
users view signed docs but requesting a signature is [[everjust-sign]].
- Granting admin / secret config — hard-blocked by the MCP anyway.
Key models (real _name + fields, Odoo 19, verified live)
| Model |
Kind |
Role |
portal.mixin |
AbstractModel |
Mixed into every "portal-shareable" business model (sale.order, account.move, project.task, project.project, etc.). Gives them access_url, access_token, access_warning, and the URL builders. A model is shareable iff it inherits this. |
portal.wizard |
TransientModel |
The "Grant portal access" batch wizard. Holds partner_ids and an auto-computed user_ids (one portal.wizard.user per contact) + a welcome_message. |
portal.wizard.user |
TransientModel |
The per-contact access action lives here. One row = one partner's portal-access decision. Methods: action_grant_access, action_revoke_access, action_invite_again. |
portal.share |
TransientModel |
The "Share" wizard — email a signed link to a specific record to partner_ids. Computes share_link from the record's _get_share_url. |
res.users |
Model |
The login. A portal user = share=True + membership in base.group_portal. Odoo 19 groups field is group_ids (m2m → res.groups) — NOT groups_id (that name was removed). |
res.partner |
Model |
The contact behind the user. _get_frontend_writable_fields() defines what a portal user may edit on their own contact (name, phone, email, address, vat, company_name). |
portal.mixin — the fields/methods you actually call
access_url (Char, computed per model) — the record's own /my/... path, e.g.
/my/invoices/<id>. Default '#'; each concrete model overrides _compute_access_url.
access_token (Char) — the per-record security token. Lazily minted by
_portal_ensure_token() (writes a uuid4 on first use). Presence of a valid
?access_token= in a URL bypasses the recipient's ACLs for that one record.
get_portal_url(suffix=, report_type=, download=, query_string=, anchor=) → the full
access_url + '?access_token=<token>...' string. This is the simplest way to mint a
shareable link. Ensures the token exists as a side effect.
_get_share_url(redirect=False, signup_partner=False, pid=None, share_token=True) →
lower-level; with redirect=True returns a /mail/view?model=..&res_id=..&access_token=..
URL (mail/view re-checks access and redirects to the portal page). pid=<partner.id>
adds a signed hash so that partner is auto-authenticated in the record's chatter.
action_share() → returns the ir.actions.act_window that opens the portal.share
wizard for the current active_id/active_model (the backend "Share" button).
portal.wizard.user — the grant/revoke engine (read this before granting)
- Fields:
partner_id (the contact), email, user_id (existing login, if any),
is_portal (bool: already a portal user), is_internal (bool: is a staff user — you
must NOT downgrade these), email_state (ok | ko invalid | exist email already a
user).
action_grant_access() — the whole grant, idempotent-guarded:
- asserts
email_state != ko/exist; raises if already is_portal/is_internal;
- creates a
res.users from template if the partner has none (_create_user, login =
normalized email);
user.write({'active': True, 'group_ids': [(4, group_portal.id), (3, group_public.id)]})
— note group_ids (adds portal group, removes public group);
partner.signup_prepare() (mints a signup token) and sends the
auth_signup.portal_set_password_email template with force_send=True.
action_revoke_access() — archives the login (active=False) and clears the signup
token. Keeps them in group_portal (never re-adds public). Only touches users where
_is_portal() is true.
action_invite_again() — just re-sends the set-password email (requires is_portal).
Recipes
Route each through the Odoo MCP (open env on the tenant DB — see [[everjust-agent-mcp]]).
The connected user needs the rights to create users / send the invite (typically an
admin/Settings role); a plain user's key will AccessError.
1. Grant a contact portal access (the invite) — the canonical path
Do NOT hand-roll a res.users. Go through portal.wizard so the group swap, signup
token, and invite email all fire correctly. Steps: create the wizard seeded with the
partner → find the auto-created portal.wizard.user row → call action_grant_access on it.
partner = env["res.partner"].search([("email", "=", "jane@acme.com")], limit=1)
# Create the wizard; user_ids is auto-computed (one portal.wizard.user per contact).
wiz = env["portal.wizard"].create({
"partner_ids": [(6, 0, partner.ids)],
"welcome_message": "Welcome to your Acme portal — your invoices and projects live here.",
})
pwu = wiz.user_ids.filtered(lambda u: u.partner_id.id == partner.id)
# Guard rails the wizard checks for you:
pwu.read(["email_state", "is_portal", "is_internal"])
# email_state == "ko" -> invalid email, fix partner.email first
# email_state == "exist" -> that email is already a login (see pitfall 4)
# is_internal == True -> STAFF user; do not grant portal, stop
# is_portal == True -> already has access; use action_invite_again instead
pwu.action_grant_access() # via MCP `call`, confirm:true (mutating)
Over MCP: create(model="portal.wizard", values={...}), then
call(model="portal.wizard.user", method="action_grant_access", ids=[<pwu_id>], confirm=true).
The customer gets a set-password email and, after choosing a password, logs in at
https://<tenant>.everjust.app/my. The return value is a wizard-refresh action — ignore it;
verify success by reading the user (recipe 4), not by the return.
2. Re-invite or revoke
# Resend the set-password email (they never clicked, token expired, etc.)
pwu.action_invite_again() # requires is_portal == True
# Revoke: archive the login (active=False). Reversible via grant again.
pwu.action_revoke_access() # requires is_portal == True; leaves them in group_portal
Both are mutating → confirm:true over MCP. Revoke does NOT delete the user or the partner;
it archives the login and voids the signup token. To re-enable, run recipe 1 again (it
flips active back to True).
3. Share ONE record by link — no login required
The fastest mint is get_portal_url() on any record whose model inherits portal.mixin
(sale.order, account.move, project.task, project.project, purchase.order, …). It ensures the
token and returns the token'd path; prepend the base URL for an absolute link.
inv = env["account.move"].browse(8891) # a shareable record
path = inv.get_portal_url() # '/my/invoices/8891?access_token=<uuid>'
full = inv.get_base_url() + path # absolute, https://<tenant>.everjust.app/...
# Variants: inv.get_portal_url(report_type="pdf", download=True) for a direct PDF download.
Over MCP: call(model="account.move", method="get_portal_url", ids=[8891], confirm=true)
(it writes the access_token on first call, hence confirm). Anyone with this URL can view
that one record without logging in — treat the link as a bearer secret.
To also email it (with a proper invite body + chatter authentication), use the
portal.share wizard instead of pasting the raw link:
share = env["portal.share"].create({
"res_model": "account.move",
"res_id": 8891,
"partner_ids": [(6, 0, partner.ids)],
"note": "Here is invoice INV/2026/0042 — click to view and pay.",
})
share.action_send_mail() # confirm:true — posts the portal.portal_share_template mail
action_send_mail is smart: partners who are already users (or when the record already has
an access_token) get the common public link; partners with no login get an individualized
signup link (_get_signup_url_for_action) so they can self-register on click. It never
creates a login itself — that's recipe 1.
4. Audit / find portal users (who has access?)
# All portal logins on this tenant:
env["res.users"].search_read(
[("share", "=", True), ("active", "=", True)],
["login", "partner_id", "group_ids", "login_date"])
# Is THIS partner a portal user? (the reliable check — via the user, not the wizard)
u = partner.with_context(active_test=False).user_ids
u.read(["login", "active", "share"]) # share==True & active==True & group_portal in group_ids
group_portal = env.ref("base.group_portal")
u._is_portal() # True iff a portal (not internal/public) user
share=True is the definitive marker of a portal (external) user. Filtering on
group_ids uses the Odoo-19 name ([("group_ids", "in", group_portal.id)]), not
groups_id.
5. Understand what a portal user will see
A logged-in portal user (share=True) sees only /my/* — never /odoo. On /my/home
they see counters for the document types linked to their partner. Visibility is enforced
by each business model's portal record rule (e.g. "portal users see account.move where
partner_id = their commercial partner"), NOT by anything you set here. So:
- Granting access ≠ choosing what they see. They see whatever the installed apps'
portal rules expose for their partner — invoices if Accounting is installed, tasks/
projects if Project is, etc. There is no per-record allow-list on the grant.
- To let them see a record their rule would NOT normally expose, share it individually
(recipe 3) — the
access_token bypasses the rule for that one record.
- Editable fields on their own contact are limited to
res.partner._get_frontend_writable_fields() (name, phone, email, address, vat,
company_name). They cannot edit arbitrary partner fields from /my/account.
6. Check whether a model is even shareable
model = env["project.task"]
isinstance(model, env.registry["portal.mixin"]) # True -> has access_url/token, shareable
# or: "access_token" in env["project.task"]._fields
If a model does not inherit portal.mixin, it has no access_url/get_portal_url
and cannot be shared via a portal link — don't try; there's no portal page for it.
Pitfalls
Portal user vs internal user = share. share=True → external/portal (billed as
free portal user, sees only /my). share=False → internal staff (consumes a paid
seat, works in /odoo). portal.wizard.user.is_internal guards this — never run
action_grant_access on a contact whose is_internal is True; you'd be mishandling a
staff account. Granting portal to a brand-new contact is the only clean path.
Odoo 19 renamed the groups field to group_ids. On res.users (and in
action_grant_access) it is group_ids, a m2m to res.groups. The old groups_id
name is gone — using it in a search domain, write, or describe-based code is a
hard error. Same everywhere you touch user groups on this platform.
Don't create the res.users by hand. create(model="res.users", {...share:True...})
skips signup_prepare(), the public→portal group swap [(4,portal),(3,public)], and the
set-password email — leaving a login the customer can't actually activate. Always go
through portal.wizard → action_grant_access.
email_state == "exist" blocks the grant. If the contact's email already matches an
existing res.users.login, the wizard refuses (a user with that email exists). Resolve
the collision first: link the partner to that existing user, or fix the email — don't
loop retrying action_grant_access.
access_token is a bearer secret. A ?access_token=<uuid> link bypasses the
recipient's ACLs for that record — anyone holding the URL can view it, logged in or
not. Share deliberately; prefer portal.share's emailed link (auditable in chatter)
over pasting a raw get_portal_url() string into an untrusted channel. To invalidate,
clear/rotate the record's access_token.
Granting access is not choosing visibility. What a portal user sees is decided by
each app's portal record rules against their partner_id, not by the grant. If they
"can't see" an expected record, the fix is the model's portal rule / the record's
partner_id, or an individual share (recipe 3) — not re-granting portal access.
The invite email rides Odoo's own mail, and needs a working mail server.
_send_email uses auth_signup.portal_set_password_email with force_send=True. If the
tenant has no configured outgoing ir.mail_server, the grant still flips the user active
but the customer never gets the link — check the mail queue / server, then
action_invite_again. (This is Odoo mail, distinct from the [[everjust-mail-ops]]
webmail stack.)
portal.share sends signup links only when auth_signup.invitation_scope == 'b2c'.
action_send_mail gives non-users an individualized signup link only if free signup
is enabled (this tenant: b2c, enabled). Otherwise even non-users just get the plain
token link and cannot self-register — they'd need an explicit grant (recipe 1).
Everything is per-tenant / per-company. Portal users, partners, and record rules are
scoped to the tenant DB and its company. Confirm you're on the right
https://<tenant>.everjust.app/mcp and env.company before granting — see
[[everjust-agent-mcp]].
Branding is cosmetic, not functional. everjust_brand_portal only rewrites the
portal "Powered by" footer → EVERJUST.APP and repoints the API-keys docs link. It adds
no fields or logic — don't look there for portal behavior; the mechanics are all in
core portal.
See also
- [[everjust-agent-mcp]] — how to open an
env/call against the right tenant over MCP.
- [[everjust-platform]] — tenancy model, portal-vs-internal seat model, what a workspace is.
- [[everjust-mail-ops]] — the native webmail stack (separate from the invite email).
- [[everjust-sign]] — requesting signatures from portal/external signers.
- [[everjust-crm-sales]] — the sale.order/account.move records customers most often view in
/my.
1---2name: everjust-client-portal3description: EVERJUST Client Portal — Agent Skill4---56# EVERJUST Client Portal — Agent Skill78Operate the **client portal**: the `/my/*` self-service frontend where a customer logs in9(or follows a signed link) to see *their own* records — quotes, invoices, sales orders,10projects, tasks, tickets, signatures. Two distinct capabilities, don't conflate them:11121. **Grant portal access** — turn a contact (`res.partner`) into a login user with13 `share=True` in `base.group_portal`, and email them a set-password invite. They then14 sign in at `https://<tenant>.everjust.app/my` and browse everything linked to their15 partner. This is `portal.wizard` → `portal.wizard.user.action_grant_access`.162. **Share one record** — send a specific partner a link to *one* backend record, gated by17 an `access_token` (bypasses their rights for that record only) or a signup token — with18 **no login required**. This is `portal.share` wizard, or directly19 `record.get_portal_url()` / `record._get_share_url(...)` on any `portal.mixin` model.2021You drive all of this through the platform's Odoo MCP (`env[...]`, `search`, `create`,22`call`) — see [[everjust-agent-mcp]] for opening the session against the right tenant. For23the tenancy model and what a workspace is for, see [[everjust-platform]].2425## When to use this skill2627- **Invite a customer to the portal** — "give jane@acme.com portal access", "let this28 contact log in and see their invoices/projects", "onboard the client to their portal".29- **Re-invite / revoke** — resend the set-password mail, or archive a portal login.30- **Share a single record by link** — "send this invoice/quote/SO/task to the customer",31 "generate a shareable link for record X" — with or without them having a login.32- **Explain portal visibility** — "why can this portal user see the invoice but not the33 contact form", "what will they see when they log in".34- **Audit portal users** — list `res.users` with `share=True`, see who has access to what.3536## When NOT to use this skill — stop if the task is really:3738- **Provisioning an INTERNAL (staff) user** — an employee who works *in* `/odoo`, not a39 customer on `/my`. That is `base.group_user` (internal), not `group_portal`, and is40 platform/admin work. A portal user has `share=True`; an internal user has `share=False`.41- **The public marketing website** (`website_connectdomain`, `website_tcsw`, the `/`42 homepage) — that is anonymous QWeb pages, not the authenticated `/my` portal. See the43 website tools in [[everjust-agent-mcp]] / [[everjust-platform]].44- **The everjust.mail webmail** (`everjust.mail.*`) — a separate product; sending the45 invite email uses Odoo's own mail templates, not that stack. See [[everjust-mail-ops]].46- **e-signature request flow** — `sign.request` has its own signer-invite path; portal47 users *view* signed docs but requesting a signature is [[everjust-sign]].48- **Granting admin / secret config** — hard-blocked by the MCP anyway.4950---5152## Key models (real `_name` + fields, Odoo 19, verified live)5354| Model | Kind | Role |55|---|---|---|56| `portal.mixin` | AbstractModel | Mixed into every "portal-shareable" business model (sale.order, account.move, project.task, project.project, etc.). Gives them `access_url`, `access_token`, `access_warning`, and the URL builders. **A model is shareable iff it inherits this.** |57| `portal.wizard` | TransientModel | The "Grant portal access" batch wizard. Holds `partner_ids` and an auto-computed `user_ids` (one `portal.wizard.user` per contact) + a `welcome_message`. |58| `portal.wizard.user` | TransientModel | **The per-contact access action lives here.** One row = one partner's portal-access decision. Methods: `action_grant_access`, `action_revoke_access`, `action_invite_again`. |59| `portal.share` | TransientModel | The "Share" wizard — email a signed link to a specific record to `partner_ids`. Computes `share_link` from the record's `_get_share_url`. |60| `res.users` | Model | The login. A **portal user = `share=True` + membership in `base.group_portal`**. Odoo 19 groups field is **`group_ids`** (m2m → `res.groups`) — **NOT `groups_id`** (that name was removed). |61| `res.partner` | Model | The contact behind the user. `_get_frontend_writable_fields()` defines what a portal user may edit on their own contact (name, phone, email, address, vat, company_name). |6263### `portal.mixin` — the fields/methods you actually call64- `access_url` (Char, computed per model) — the record's own `/my/...` path, e.g.65 `/my/invoices/<id>`. Default `'#'`; each concrete model overrides `_compute_access_url`.66- `access_token` (Char) — the per-record security token. Lazily minted by67 `_portal_ensure_token()` (writes a `uuid4` on first use). Presence of a valid68 `?access_token=` in a URL **bypasses the recipient's ACLs for that one record**.69- `get_portal_url(suffix=, report_type=, download=, query_string=, anchor=)` → the full70 `access_url + '?access_token=<token>...'` string. **This is the simplest way to mint a71 shareable link.** Ensures the token exists as a side effect.72- `_get_share_url(redirect=False, signup_partner=False, pid=None, share_token=True)` →73 lower-level; with `redirect=True` returns a `/mail/view?model=..&res_id=..&access_token=..`74 URL (mail/view re-checks access and redirects to the portal page). `pid=<partner.id>`75 adds a signed `hash` so that partner is auto-authenticated in the record's chatter.76- `action_share()` → returns the `ir.actions.act_window` that opens the `portal.share`77 wizard for the current `active_id`/`active_model` (the backend "Share" button).7879### `portal.wizard.user` — the grant/revoke engine (read this before granting)80- Fields: `partner_id` (the contact), `email`, `user_id` (existing login, if any),81 `is_portal` (bool: already a portal user), `is_internal` (bool: is a staff user — you82 must NOT downgrade these), `email_state` (`ok` | `ko` invalid | `exist` email already a83 user).84- `action_grant_access()` — the whole grant, idempotent-guarded:85 1. asserts `email_state != ko/exist`; raises if already `is_portal`/`is_internal`;86 2. creates a `res.users` from template if the partner has none (`_create_user`, login =87 normalized email);88 3. `user.write({'active': True, 'group_ids': [(4, group_portal.id), (3, group_public.id)]})`89 — **note `group_ids`** (adds portal group, removes public group);90 4. `partner.signup_prepare()` (mints a signup token) and sends the91 `auth_signup.portal_set_password_email` template with `force_send=True`.92- `action_revoke_access()` — archives the login (`active=False`) and clears the signup93 token. Keeps them in `group_portal` (never re-adds public). Only touches users where94 `_is_portal()` is true.95- `action_invite_again()` — just re-sends the set-password email (requires `is_portal`).9697---9899## Recipes100101Route each through the Odoo MCP (open `env` on the tenant DB — see [[everjust-agent-mcp]]).102The connected user needs the rights to create users / send the invite (typically an103admin/Settings role); a plain user's key will `AccessError`.104105### 1. Grant a contact portal access (the invite) — the canonical path106107**Do NOT hand-roll a `res.users`.** Go through `portal.wizard` so the group swap, signup108token, and invite email all fire correctly. Steps: create the wizard seeded with the109partner → find the auto-created `portal.wizard.user` row → call `action_grant_access` on it.110111```python112partner = env["res.partner"].search([("email", "=", "jane@acme.com")], limit=1)113114# Create the wizard; user_ids is auto-computed (one portal.wizard.user per contact).115wiz = env["portal.wizard"].create({116 "partner_ids": [(6, 0, partner.ids)],117 "welcome_message": "Welcome to your Acme portal — your invoices and projects live here.",118})119pwu = wiz.user_ids.filtered(lambda u: u.partner_id.id == partner.id)120121# Guard rails the wizard checks for you:122pwu.read(["email_state", "is_portal", "is_internal"])123# email_state == "ko" -> invalid email, fix partner.email first124# email_state == "exist" -> that email is already a login (see pitfall 4)125# is_internal == True -> STAFF user; do not grant portal, stop126# is_portal == True -> already has access; use action_invite_again instead127128pwu.action_grant_access() # via MCP `call`, confirm:true (mutating)129```130Over MCP: `create(model="portal.wizard", values={...})`, then131`call(model="portal.wizard.user", method="action_grant_access", ids=[<pwu_id>], confirm=true)`.132The customer gets a set-password email and, after choosing a password, logs in at133`https://<tenant>.everjust.app/my`. The return value is a wizard-refresh action — ignore it;134verify success by reading the user (recipe 4), not by the return.135136### 2. Re-invite or revoke137138```python139# Resend the set-password email (they never clicked, token expired, etc.)140pwu.action_invite_again() # requires is_portal == True141142# Revoke: archive the login (active=False). Reversible via grant again.143pwu.action_revoke_access() # requires is_portal == True; leaves them in group_portal144```145Both are mutating → `confirm:true` over MCP. Revoke does NOT delete the user or the partner;146it archives the login and voids the signup token. To re-enable, run recipe 1 again (it147flips `active` back to True).148149### 3. Share ONE record by link — no login required150151The fastest mint is `get_portal_url()` on any record whose model inherits `portal.mixin`152(sale.order, account.move, project.task, project.project, purchase.order, …). It ensures the153token and returns the token'd path; prepend the base URL for an absolute link.154155```python156inv = env["account.move"].browse(8891) # a shareable record157path = inv.get_portal_url() # '/my/invoices/8891?access_token=<uuid>'158full = inv.get_base_url() + path # absolute, https://<tenant>.everjust.app/...159# Variants: inv.get_portal_url(report_type="pdf", download=True) for a direct PDF download.160```161Over MCP: `call(model="account.move", method="get_portal_url", ids=[8891], confirm=true)`162(it *writes* the access_token on first call, hence confirm). Anyone with this URL can view163that one record without logging in — treat the link as a bearer secret.164165To also **email** it (with a proper invite body + chatter authentication), use the166`portal.share` wizard instead of pasting the raw link:167168```python169share = env["portal.share"].create({170 "res_model": "account.move",171 "res_id": 8891,172 "partner_ids": [(6, 0, partner.ids)],173 "note": "Here is invoice INV/2026/0042 — click to view and pay.",174})175share.action_send_mail() # confirm:true — posts the portal.portal_share_template mail176```177`action_send_mail` is smart: partners who are already users (or when the record already has178an `access_token`) get the common public link; partners with no login get an individualized179**signup** link (`_get_signup_url_for_action`) so they can self-register on click. It never180creates a login itself — that's recipe 1.181182### 4. Audit / find portal users (who has access?)183184```python185# All portal logins on this tenant:186env["res.users"].search_read(187 [("share", "=", True), ("active", "=", True)],188 ["login", "partner_id", "group_ids", "login_date"])189190# Is THIS partner a portal user? (the reliable check — via the user, not the wizard)191u = partner.with_context(active_test=False).user_ids192u.read(["login", "active", "share"]) # share==True & active==True & group_portal in group_ids193group_portal = env.ref("base.group_portal")194u._is_portal() # True iff a portal (not internal/public) user195```196`share=True` is the definitive marker of a portal (external) user. Filtering on197`group_ids` uses the **Odoo-19 name** (`[("group_ids", "in", group_portal.id)]`), not198`groups_id`.199200### 5. Understand what a portal user will see201202A logged-in portal user (`share=True`) sees only `/my/*` — never `/odoo`. On `/my/home`203they see counters for the document types linked to *their* partner. Visibility is enforced204by each business model's **portal record rule** (e.g. "portal users see account.move where205`partner_id` = their commercial partner"), NOT by anything you set here. So:206- Granting access ≠ choosing what they see. They see whatever the installed apps'207 portal rules expose for their partner — invoices if Accounting is installed, tasks/208 projects if Project is, etc. There is no per-record allow-list on the grant.209- To let them see a record their rule would NOT normally expose, **share it individually**210 (recipe 3) — the `access_token` bypasses the rule for that one record.211- Editable fields on their own contact are limited to212 `res.partner._get_frontend_writable_fields()` (name, phone, email, address, vat,213 company_name). They cannot edit arbitrary partner fields from `/my/account`.214215### 6. Check whether a model is even shareable216217```python218model = env["project.task"]219isinstance(model, env.registry["portal.mixin"]) # True -> has access_url/token, shareable220# or: "access_token" in env["project.task"]._fields221```222If a model does **not** inherit `portal.mixin`, it has no `access_url`/`get_portal_url`223and cannot be shared via a portal link — don't try; there's no portal page for it.224225---226227## Pitfalls2282291. **Portal user vs internal user = `share`.** `share=True` → external/portal (billed as230 free portal user, sees only `/my`). `share=False` → internal staff (consumes a paid231 seat, works in `/odoo`). `portal.wizard.user.is_internal` guards this — **never run232 `action_grant_access` on a contact whose `is_internal` is True**; you'd be mishandling a233 staff account. Granting portal to a brand-new contact is the only clean path.2342352. **Odoo 19 renamed the groups field to `group_ids`.** On `res.users` (and in236 `action_grant_access`) it is `group_ids`, a m2m to `res.groups`. The old `groups_id`237 name is **gone** — using it in a `search` domain, `write`, or `describe`-based code is a238 hard error. Same everywhere you touch user groups on this platform.2392403. **Don't create the `res.users` by hand.** `create(model="res.users", {...share:True...})`241 skips `signup_prepare()`, the public→portal group swap `[(4,portal),(3,public)]`, and the242 set-password email — leaving a login the customer can't actually activate. Always go243 through `portal.wizard` → `action_grant_access`.2442454. **`email_state == "exist"` blocks the grant.** If the contact's email already matches an246 existing `res.users.login`, the wizard refuses (a user with that email exists). Resolve247 the collision first: link the partner to that existing user, or fix the email — don't248 loop retrying `action_grant_access`.2492505. **`access_token` is a bearer secret.** A `?access_token=<uuid>` link **bypasses the251 recipient's ACLs for that record** — anyone holding the URL can view it, logged in or252 not. Share deliberately; prefer `portal.share`'s emailed link (auditable in chatter)253 over pasting a raw `get_portal_url()` string into an untrusted channel. To invalidate,254 clear/rotate the record's `access_token`.2552566. **Granting access is not choosing visibility.** What a portal user sees is decided by257 each app's portal **record rules** against their `partner_id`, not by the grant. If they258 "can't see" an expected record, the fix is the model's portal rule / the record's259 `partner_id`, or an individual share (recipe 3) — not re-granting portal access.2602617. **The invite email rides Odoo's own mail, and needs a working mail server.**262 `_send_email` uses `auth_signup.portal_set_password_email` with `force_send=True`. If the263 tenant has no configured outgoing `ir.mail_server`, the grant still flips the user active264 but the customer never gets the link — check the mail queue / server, then265 `action_invite_again`. (This is Odoo mail, distinct from the [[everjust-mail-ops]]266 webmail stack.)2672688. **`portal.share` sends signup links only when `auth_signup.invitation_scope == 'b2c'`.**269 `action_send_mail` gives non-users an individualized **signup** link only if free signup270 is enabled (this tenant: `b2c`, enabled). Otherwise even non-users just get the plain271 token link and cannot self-register — they'd need an explicit grant (recipe 1).2722739. **Everything is per-tenant / per-company.** Portal users, partners, and record rules are274 scoped to the tenant DB and its company. Confirm you're on the right275 `https://<tenant>.everjust.app/mcp` and `env.company` before granting — see276 [[everjust-agent-mcp]].27727810. **Branding is cosmetic, not functional.** `everjust_brand_portal` only rewrites the279 portal "Powered by" footer → EVERJUST.APP and repoints the API-keys docs link. It adds280 no fields or logic — don't look there for portal *behavior*; the mechanics are all in281 core `portal`.282283## See also284- [[everjust-agent-mcp]] — how to open an `env`/`call` against the right tenant over MCP.285- [[everjust-platform]] — tenancy model, portal-vs-internal seat model, what a workspace is.286- [[everjust-mail-ops]] — the native webmail stack (separate from the invite email).287- [[everjust-sign]] — requesting signatures from portal/external signers.288- [[everjust-crm-sales]] — the sale.order/account.move records customers most often view in `/my`.