Portal Developer Guide
How to extend a Flourish Core portal with your own Apex and Lightning web components, without stepping outside the portal's access rules.
Overview
Configuration covers most portals. When it runs out, there are four places to add code:
| Hook | Named in | Does |
|---|---|---|
| Role resolver | A portal's roleRules | Decides which roles a person holds. |
| Apex scope rule | A permission set's scope | Decides which records a set reaches. |
| Record page action | A record page's actions | Does something on a record that the person could not do by hand. |
| Component | A component card | Draws something the cards do not, reaching data through a provider class on the portal's rules. |
Portals and Portal configuration cover everything that needs no code.
The Apex side of every hook
Every hook is an Apex class that implements Salesforce's own System.Callable. Flourish Core ships no interface for you to implement.
- Make the class
global. The portal finds it by name from inside the managed package, and apublicclass cannot be found that way. - Name it in configuration by its name, with its namespace if your org has one:
AcmeRoles, oracme.AcmeRoles. - It runs in system mode, as the portal does. Your code is trusted to decide, so it should check what it needs to.
Each hook is handed the person it is acting for, as a map:
| Key | Holds |
|---|---|
audience | licensed, internal, arbor or guest. |
userId | Their Salesforce user, for licensed and internal users only. Guests and Arbor members all reach a site as its guest user, so they have none. |
contactId, accountId | A licensed user's Contact and its Account, or an Arbor member's when they are tied to a Contact or Account. |
recordId | Their own record: a licensed user's Contact, or the record an Arbor member is tied to. |
elementId, networkId | An Arbor member's Arbor Element and network. |
roles | Their portal roles, once worked out. |
Any of them can be null, so check before you use one.
Role resolvers
A role resolver gives roles that a rule on a field cannot express: "board members of any Account in this list", "anyone with an open grant".
"roleRules": [ { "audience": "licensed", "resolver": "AcmeRoles" } ]
The portal calls it with the action roles and { portal, principal }, and takes back a list of role names:
global class AcmeRoles implements System.Callable {
global Object call(String action, Map<String, Object> args) {
if (action != 'roles') {
return null;
}
Map<String, Object> principal = (Map<String, Object>) args.get('principal');
Id contactId = (Id) principal.get('contactId');
if (contactId == null) {
return new List<String>();
}
List<String> roles = new List<String>();
if ([SELECT COUNT() FROM Board_Seat__c WHERE Contact__c = :contactId AND Active__c = true] > 0) {
roles.add('board');
}
return roles;
}
}
Role names are compared in lower case. The person's roles are still empty at this point.
Do not let people edit what your resolver reads. The portal protects the fields a role rule names from being changed through the portal, but it cannot know which fields your class reads. If a permission set lets someone edit one, they can edit their way into a role.
Arbor's own resolver, toflourish.arbor_CoreBridge, is how a portal reads Arbor members' roles; see Portals.
Apex scope rules
An Apex scope rule reaches whatever records your class returns, for reach no declarative rule can describe: "projects where my account holds a role".
"scope": [ { "kind": "apex", "className": "AcmeProjectScope", "access": "edit" } ]
The portal calls it with the action scope and { portal, object, principal }, where object is the object's API name and principal has its roles. Return the Ids the rule reaches, as a Set<Id> or a List<Id>:
global class AcmeProjectScope implements System.Callable {
global Object call(String action, Map<String, Object> args) {
Map<String, Object> principal = (Map<String, Object>) args.get('principal');
Id accountId = (Id) principal.get('accountId');
Set<Id> ids = new Set<Id>();
if (accountId == null) {
return ids;
}
for (Project_Role__c r : [SELECT Project__c FROM Project_Role__c WHERE Account__c = :accountId]) {
ids.add(r.Project__c);
}
return ids;
}
}
An empty answer reaches nothing. The rows your class reads count against the request's query limits, and an answer bigger than the portal's scopeRowBudget (20,000 by default) is refused rather than cut short. The answer is reused for the rest of the request, so the class runs at most once per set, object and access level each time.
Record page actions
An update or create action can only do what the person could do by hand. An apex action runs your class instead, for anything more: closing a case the person may not otherwise edit, creating a follow-up somewhere they cannot write.
{ "key": "followup", "label": "Add a follow-up task", "kind": "apex",
"className": "CaseActions", "access": "edit", "confirm": "Add a follow-up task?" }
Before calling it, the portal checks that the person can see the page and holds the action's roles, and that the record is within their reach at the action's access: edit, the default, or read. Then it calls the class with the action action and { action, objectName, recordId, portal, principal }, where action is the action's key, so one class can answer several actions.
global class CaseActions implements System.Callable {
global Object call(String action, Map<String, Object> args) {
Id caseId = (Id) args.get('recordId');
Date due = Date.today().addDays(3);
insert new Task(WhatId = caseId, Subject = 'Follow up', ActivityDate = due);
return new Map<String, Object>{ 'message' => 'A follow-up task is due ' + due.format() + '.' };
}
}
Answer with a map:
| Key | Does |
|---|---|
message | Shown under the record's header. |
error | Shown to the person, and everything the class did is undone. |
refresh | false keeps the page from reloading afterwards. It reloads by default. |
The class name stays on the server. The browser only ever knows the action's key.
Components in a portal
A component card draws a Lightning web component by name: c/name for one of yours, or namespace/name for one in a package.
{ "kind": "component", "component": "c/eligibilityCard", "provider": "EligibilityProvider",
"properties": { "heading": "Your eligibility" } }
The component is handed:
| Property | Holds |
|---|---|
Each key of properties | Its value, as text. Declare an @api property for each. |
recordId, objectApiName | On a record page, the record and its object, as on a Lightning record page. |
portalContext | The portal's services, below. |
roles on the card decides who sees it, and nothing more.
On an Experience site, a component loads only if something on the page already imports it. Put a small component of your own on the same page as the portal, whose JavaScript imports each component your cards name. Without it, the card shows This content could not be loaded. A Lightning page has no such limit.
Reaching data: providers
A component that calls its own Apex is outside the portal's rules. The call runs as the person viewing, and nothing the portal's permission sets say applies to it. That can be fine for staff on a Lightning page. On a site it fails for guests and Arbor members, and granting the class to the site's guest user to make it work would open it to everyone on the internet. Never do that.
Reach data through a provider instead: a global class implementing System.Callable, named in the card's provider. The component calls it through portalContext.invoke, and the portal calls the provider only for someone who can see a card naming it, handing it the portal's rules as an access object.
import { LightningElement, api } from 'lwc';
export default class EligibilityCard extends LightningElement {
@api portalContext;
@api recordId;
@api heading;
summary;
async connectedCallback() {
if (!this.portalContext) {
return;
}
this.summary = await this.portalContext.invoke('EligibilityProvider', 'summary', {});
}
}
global class EligibilityProvider implements System.Callable {
global Object call(String action, Map<String, Object> args) {
Callable access = (Callable) args.get('access');
if (action == 'summary') {
Map<String, Object> scope = (Map<String, Object>) access.call('scope',
new Map<String, Object>{ 'object' => 'Case', 'level' => 'read' });
if (scope == null) {
return new Map<String, Object>{ 'error' => 'You do not have access to this.' };
}
String whereClause = (String) scope.get('sql');
Map<String, Object> binds = (Map<String, Object>) scope.get('binds');
Integer open = Database.countQueryWithBinds(
'SELECT COUNT() FROM Case WHERE IsClosed = false'
+ (whereClause == null ? '' : ' AND (' + whereClause + ')'),
binds, AccessLevel.SYSTEM_MODE);
return new Map<String, Object>{ 'openCases' => open };
}
return new Map<String, Object>{ 'error' => 'Unknown action.' };
}
}
The provider gets whatever the component passed as args, plus access. An answer holding error is shown to the person as a refusal; anything else goes back to the component as it is.
Query in system mode, add the portal's condition, and show only granted fields. access answers:
| Call | With | Answers |
|---|---|---|
principal | nothing | The person, as described above, with displayName. |
scope | { object, level }, level being read or edit | { object, sql, binds, fields }: a condition to add to your query with its bind values (sql is null when every record is in reach), and the fields every set reaching the object grants. Null when the person has no such access. |
reach | { object, ids, level } | The Ids from ids the person reaches at that level. |
fields | { object } | { readable, creatable, editable }: the fields the person may read, create and edit. |
names | { ids } | Each Id's name, for records the person may see the name of. |
save | { object, recordId, values } | Saves through every check a portal save makes, creating when there is no recordId, and answers { recordId, record }. |
A provider in another Flourish package is the same, except that the class is public and @NamespaceAccessible rather than global. That is how the activity timeline runs in a portal.
portalContext
portalContext is set only inside a portal. In Lightning it is absent, so a component can check it and behave as it always does.
| Member | Is |
|---|---|
audience, displayName, portalLabel | Who is viewing and in which portal. For shaping what the component shows, never for deciding access. |
recordId, objectApiName | The record the card sits on, if any. |
invoke(provider, action, args) | Calls a provider, as above. Returns a promise of its answer. provider has to match the card's provider. |
openForm({ label, objectName, fields, values, defaults, recordId }) | Opens the portal's own record form, which reads and saves on the portal's rules. Returns a promise of { recordId, readable, name }, or undefined if the person cancels. |
navigate({ recordId }) or navigate({ pageKey }) | Opens a record on its portal page, or one of the portal's pages. |
notify({ title, message, variant }) | Shows a message at the foot of the window, since a site has no toasts. variant is success, info, warning or error. |
A component can also tell the portal it holds unsaved work, so the portal asks before anyone leaves the page. Dispatch portaldirty with { key, dirty }, where key is any name unique to the component, and set bubbles and composed:
this.dispatchEvent(new CustomEvent('portaldirty', {
detail: { key: 'eligibilityCard', dirty: true }, bubbles: true, composed: true
}));