Portal screens and actions
How to give one site its own portal path, routes and hidden screens, declare portal screens with scoped data and portal actions that write documents or custom fields, restyle the reference portal's templates, and build a portal of your own on the member API.
engine-dmo
The engine’s reference portal covers the work every partner does. A site can go further without writing Go: it can move and trim its portal, add screens (pages with data the engine reads for the partner’s own organisation) and actions (forms that write a document of the organisation or its custom fields), and restyle every template. Or it can switch the screens off and build its own portal on the member API.
All of this lives in one file per site, config/sites/<site>/portal.yaml, beside templates under the site’s template chain.
A screen and an action
path: /partners
hide: [events]
screens:
- key: overview
path: /organisations/{org}/overview
template: overview.html
title: { en-GB: Overview }
nav: { group: business, order: 10 }
can: listing.update
data:
listings: { documents: { type: listing, states: [draft, published], sort: -updatedAt, limit: 5 } }
figures: { report: { range: last_month } }
membership: { membership: {} }
waiting: { pending: { limit: 5 } }
nearby: { published: { type: listing, near: $org.listing, within: 2km, sort: distance, limit: 6 } }
actions:
- key: preferences.update
label: { en-GB: Update your preferences }
can: organisation.update
target:
organisation: { fields: [custom.preferredContactTime, custom.coachParking] }
rate_limit: 20/hour/person
respond: { redirect: { screen: overview } } {% extends "_layout.html" %}
{% block title %}{{ screen.title }}{% endblock %}
{% block main %}
<h1>{{ screen.title }}</h1>
<ul>
{% for l in data.listings.items %}<li>{% if l.href %}<a href="{{ l.href|url }}">{{ l.title }}</a>{% else %}{{ l.title }}{% endif %}</li>{% endfor %}
</ul>
{% set f = portal_form('preferences.update') %}
<form {{ f.attrs }}>
{{ f.hidden }}
{% for field in f.fields %}{{ field_widget(field, f.errors) }}{% endfor %}
<p><button class="p-button" type="submit">{{ f.label }}</button></p>
</form>
{% endblock %} The action writes custom fields declared in config/crm/organisation.yaml (see CRM). The template extends _layout.html, the portal’s layout, which engine-dmo portal scaffold --site main copies into templates/main/portal/ (see Restyling the portal).
Then:
engine-dmo check --fix --project .
engine-dmo portal check --project . check --fix writes the screen’s scr_ id and the action’s act_ id. engine-dmo dev portal screen add and engine-dmo dev portal action add write the same entries from flags, with --scaffold writing a starting template for a screen.
Per-site portals
config/sites/<site>/portal.yaml is laid over the install’s config/portal/portal.yaml. Anything it leaves out keeps the install’s value.
| Key | Meaning |
|---|---|
mode | templated, api, both or off, for this site |
path | Where the portal answers on this site’s host, such as /partners. Defaults to server.portal_path. |
template_dir | The directory under each of the site’s template roots that holds its portal templates |
routes.<screen> | A reference screen’s path under the portal path, as in the install file |
hide | Reference screens this site does not serve: any of contact_details, contact_team, enquiries, events, hours, listing_edit, membership, people, photos, promotions, report |
screens | The site’s own screens, and replacements of reference screens’ templates |
actions | The site’s own actions |
The report rules and invitation timings stay install-wide. A link the engine builds to a hidden screen (in the portal, in mail, in the admin) is never made.
Screens
A screen is a page under the portal path, rendered from a template under the site’s portal template directory, with declared data the engine reads for the person’s active organisation. Templates never query; the data is a closed set of sources, each scoped in the database to the organisation, the person’s role and the site’s network.
| Key | Required | Meaning |
|---|---|---|
key | yes | Lowercase letters, digits and underscores. Unique on the site. |
id | written by check --fix | The stable id (scr_) |
path | yes, for a new screen | The path under the portal path, with {org}, {id} or no placeholder. Must not match the same addresses as another screen. |
template | yes | An .html file under the site’s portal template directory, found along the site’s template chain |
title | no | The title per locale, which the navigation shows; the key humanised when absent |
nav | no | { group: <lowercase word>, order: <number> } puts the screen in the portal navigation. group defaults to business. A screen with {id} cannot be in the navigation. |
can | no | The partner permission it needs: a built-in permission, another declared action.<key>, or screen.<key>. Defaults to screen.<key>, which a role must grant (the built-in manager holds screen.*). |
tiers | no | The tiers whose organisations may open it, as a tier list. Needs {org} or {id} in the path. |
data | no | Named data sources, read in the order declared |
A screen keyed as a reference screen (organisation, report, people and so on) replaces that screen’s template and nothing else: it takes only key and template, keeps the reference screen’s path, permission and context, and cannot replace a hidden screen.
Data sources
Each entry under data: is <name>: { <source>: { <parameters> } }, and the template reads it as data.<name>. A list source gives { denied, items, count }; when the person’s role lacks a source’s extra permission, denied is true and items is empty.
| Source | Parameters | Gives | Extra permission |
|---|---|---|---|
documents | type (required), states (draft, published or both; default both), sort (updatedAt, createdAt or title, - for descending), limit (default 50, at most 100) | The organisation’s documents of the type. Each item: id, type, title, state, live, pending, site, created, updated, fields, href | |
document | type (required) | The document the path’s {id} names, which must be the organisation’s. Needs {id} in the path. | |
published | type (required), near ($org.listing), within (a distance such as 5km or 800m, needs near), from (today, for events with an occurrence from today), sort (name, recent or distance; distance needs near), limit (default 10, at most 100) | Published documents of the type on the site. Each item: id, type, title, summary, uri, url, tags. With near, around the organisation’s published listing. | |
report | range (last_12_months or last_month) | The organisation’s member report | report.view |
enquiries | since (a period such as 30d), status (open, new, read, replied, archived, all; default open), limit (default 50, at most 200) | Its enquiries | enquiries.view |
pending | limit (default 50, at most 200) | Its changes waiting for staff. Each item: id, document, type, title, status, submitted, fields, yours, href | pending.view |
people | none ({}) | Its people: name, partner_role, is_primary, you | |
membership | none | Its memberships by site: site, site_name, current, tier, status, joined_on | membership.view |
organisation | none | The organisation: id, name, legal_name, email, website, phone, address, social, custom | |
promotions | limit (default 50, at most 200) | Its promotion requests | promotion.request |
first_session | none | The first-session checklist: steps (each label, href, done), remaining, total, complete |
Every source except published without near reads the active organisation, so the screen’s path needs {org} or {id}; check says so at the line.
The screen template’s context
| Name | What it is |
|---|---|
screen | key, id, title, path |
data.<name> | Each declared source |
organisation | The active organisation, with its links to the reference screens and what the person may do (organisation.can.listing_update and so on) |
member | The signed-in person: id, name, displayName, email, organisations, activeOrganisation |
person | The signed-in person’s names and email |
portal | path, login, logout, account, assets, nav (the site’s own screens in the navigation), and portal.route(screen, id), the path of any screen |
portal_form(key) | A declared action’s form; see below |
field_widget(field, errors) | One accessible control for a field of a form |
component(name, ...) | One of the shipped components: asset, error_summary, field, link, opening_hours, place, portable_text, recurrence, status |
site, locale, csrf, request, t() | As on the public site, plus the public site’s globals |
t() reads the site’s string catalogue first and then the portal’s own strings; portal check warns about a key in neither.
Actions
An action is a form the portal posts to {portal path}/actions/<key> (with ?id= for a document it edits, ?org= otherwise). It writes declared fields of one target, and it is refused unless the person’s role grants its permission and the organisation’s tier is one the action allows; both are decided in the database.
| Key | Required | Meaning |
|---|---|---|
key | yes | <subject>.<verb>, lowercase, one dot. The verb is one of create, update, remove, add, submit, request, invite. A built-in action’s key (profile.update, organisation.update, listing.update, photos.add, photos.remove, hours.update, people.invite, people.role, event.submit, promotion.request) cannot be reused. |
id | written by check --fix | The stable id (act_) |
label | no | Its name per locale where a screen lists it; the key when absent |
can | no | The partner permission it needs. Defaults to action.<key>. |
tiers | no | The tiers whose organisations may use it; every tier and none when absent |
target | yes | One of document, organisation, person; see below |
fields | for a document target | The document fields it writes |
approval | no | Document targets only. tier (the default): the tier’s member_editable rule decides whether the change waits. required: every change waits for staff. |
rate_limit | no | <count>/<window>/<key>, keyed person, organisation, session or ip, such as 20/hour/person |
respond | no | { redirect: { screen: <screen> } }, a reference screen or one of the site’s own, answered with 303 after a good post. Absent, the post returns to the screen the form was on. |
Targets:
| Target | Writes |
|---|---|
document: { type: offer, create: true } | A new document of the type, owned by the active organisation |
document: { type: offer, id: $id } | The document named by ?id=, which must be the organisation’s |
organisation: { fields: [custom.<name>, ...] } | The active organisation’s custom fields, at once |
person: { fields: [custom.<name>, ...] } | The signed-in person’s custom fields, at once |
A document action’s fields are also held to the tier’s member_editable rule: a field no tier the action allows may edit is warned about at load, and a save keeps only what the tier’s rule allows.
A document action from the reference example, which lets silver and gold businesses add offers:
actions:
- key: offer.create
label: { en-GB: Add an offer }
can: listing.update
tiers: { min: silver }
target:
document: { type: offer, create: true }
fields: [title, summary, validUntil, bookingEmail, heroImage]
approval: tier
rate_limit: 20/hour/person
respond: { redirect: { screen: offers } } Rendering an action’s form
portal_form('<key>') (or portal_form('<key>', id) for a document it edits) returns the form for the active organisation. It is a template error when the site has no such action or the person may not use it, so a screen never shows a form that would be refused; wrap it in a check of the person’s permission when a screen is open to people who cannot use the action.
| Property | Meaning |
|---|---|
attrs | The form element’s attributes: method, action, and the multipart encoding when a field uploads |
hidden | The hidden inputs (CSRF token, return address) |
fields | The form description’s fields, each rendered with field_widget(field, f.errors) |
errors | Field errors after a failed post, with _summary and _count for an error summary (component('error_summary', f)) |
label, key, id, action | The action’s label, key, id and post address |
approval | required when changes will wait for staff |
The reference organisation screen lists every action the person’s role and tier allow that needs no document (actions in its context).
Restyling the portal
The engine renders its own templates until a file of the same name exists in the site’s portal template directory, templates/<root>/<template_dir>/ along the site’s template chain. Copy the reference portal there to start:
engine-dmo portal scaffold --site main This writes about forty files (screens, the _layout.html they extend, the claim flow and components/) into the site’s first template root, such as templates/main/portal/. Existing files are left alone unless you pass --force. Always pass --site. Without it the files go to templates/portal/, which is on no site’s template chain unless one of your roots is named portal, so none of them is ever rendered; check then warns that nothing in the folder renders. If that happened, delete templates/portal/ and run the command again with --site. To share one restyled portal between sites, pass --out templates/_shared/portal.
Then edit what you need and delete what you do not: a template you delete falls back to the engine’s.
| Command | What it does |
|---|---|
engine-dmo portal scaffold [--site <site>] [--out <dir>] [--force] | Copy the reference portal’s templates into the project |
engine-dmo portal check | Print each site’s mode, path, template directory and screen routes, and check the portal templates against the portal context |
engine-dmo portal status | What each site’s portal is serving and which templates the project overrides (needs the database) |
The portal’s strings are the engine’s own catalogue; a project overrides one by adding the same key to config/strings/<locale>.yaml.
The member API
The member API serves the same data and the same rules as the portal as JSON, for a portal built with another toolchain. It is on when server.api.member.enabled is true (the default) and the site’s portal mode is api or both.
| Base path | /api/member/v1 on the site’s host |
| Authentication | The member session cookie (__Host-mrd_member) set by the portal’s sign-in screens, same origin only |
| Unsafe requests | Carry the CSRF token from GET /me in X-CSRF-Token |
| Errors | RFC 9457 problem details; a 422 carries field_errors with structured paths |
| Rate limit | endpoints.rate_limit.member per session on every write, and four times that per address |
| Contract | GET /api/member/v1/openapi.json, or engine-dmo api member openapi |
| Operation | Method and path | Permission |
|---|---|---|
getMe | GET /me | The person, their organisations and the CSRF token |
getOrganisation, patchOrganisation | GET, PATCH /organisations/{id} | organisation.update for details, hours.update for opening hours |
listListings | GET /organisations/{id}/listings | The organisation’s documents |
getListing, patchListing | GET, PATCH /listings/{id} | listing.update; fields the tier does not permit are discarded and recorded |
getListingForm | GET /listings/{id}/form | The form description for this listing, person and tier |
getListingStatus | GET /listings/{id}/status | What is live, pending or returned, and the expected turnaround |
listListingPhotos, arrangeListingPhotos | GET, POST /listings/{id}/photos | Photos |
listOrganisationAssets | GET /organisations/{id}/assets | The organisation’s own uploads |
uploadAsset, patchAsset | POST /assets, PATCH /assets/{id} | Uploads (multipart), alt text and caption |
getUploadTerms | GET /upload-terms | The terms a member accepts with an upload |
listPeople, invitePerson, setPersonRole, removePerson | /organisations/{id}/people, /people/{link} | people.invite, people.manage |
listInvitations | GET /organisations/{id}/invitations | Open invitations |
getMembership | GET /organisations/{id}/membership | Memberships by site |
getReport | GET /organisations/{id}/report | The member report: the last twelve months by default, range=last_month or month=2026-08 |
getActivity | GET /organisations/{id}/activity | Member-visible timeline entries |
contactTeam | POST /organisations/{id}/contact | A message to the team, recorded on the timeline |
listEvents | GET /events | The organisations’ event documents |
engine-dmo api member routes # every operation and whether this project serves it
engine-dmo api member openapi # the OpenAPI 3.1 document
engine-dmo api member form --listing <id> --person <id> # the form a given member would receive Declared screens and actions are portal features; the member API does not expose them, and event submission has no API operation yet.
Troubleshooting
Message from check | Fix |
|---|---|
the <source> source reads the active organisation's data, and this screen's path has no {org} or {id} | Add {org} to the path, such as /organisations/{org}/<key>. |
"<name>" is not a screen data source | Use one of the sources in the table. |
screen <key> replaces the reference screen's template and takes no path | A screen keyed as a reference screen takes template only; give a new screen its own key. |
unknown partner permission "<name>" | Use a built-in permission, an action.<key> or a screen.<key>. |
action key "<key>" is <subject>.<verb>: lowercase, one dot | Rename it, with one of the allowed verbs. |
an action with input and steps ... is not built yet | Give the action a target; a form that only stores a message is a declared endpoint with access: member. |
tier "<tier>" is not sold on site <site> (warning) | List the tier in the site’s memberships.tiers, or drop it from the rule. |
Partners and the portal
Membership tiers and memberships, what each tier may edit on a listing, partner roles and their permissions, the member portal's sign-in, invitations, reference screens and configuration, and the enquiry inbox businesses work from.
Claims
How a business claims its organisation from the public site, how the claim is verified and scored from weighted signals, how staff review it, what approval does, every claims setting in engine.yaml and the claim commands.