Customer portal
Give your customers a portal — profile, billing, invoices, support and notifications. Run the ready-made portal (white-label from Control Plane config) or embed the pieces with the SDK.
A portal your customers already expect
Every B2B SaaS needs the same customer-facing surface: sign-in, profile, billing and invoices, payment methods, MFA enrolment, a support inbox, notifications and a knowledge base. The platform ships all of it as a customer portal — a React app wired to the platform services — so you do not rebuild it. It is Stripe's Customer Portal idea, extended past billing to your whole product.
There are two honest ways to put it in front of your customers today.
Option 1 — run the ready-made portal
The customer portal is a standalone app you run as your own. It comes wired to identity, billing, support and notifications out of the box, and it is white-label from Control Plane config — you change what it looks like, not how it works.
What ships in it:
- Sign-in and sign-up screens, in your UI — customers never see an IdP page (see Auth & the five gates).
- Profile, password reset and MFA enrolment.
- Billing: subscriptions, plan changes, invoices and payment methods — the same subscriptions surface.
- A support inbox — tickets, comments, attachments and CSAT — and a searchable knowledge base.
- A notifications inbox.
Branding is runtime, not a rebuild. Your logo, primary colour, portal name and favicon are set in the Control Plane — upload a logo or favicon file, or paste a URL, and pick the colour in Settings → Branding — and the portal applies them on the next load. No rebuild, no redeploy. At boot the app reads the rest of its config too — service URLs, enabled login methods, org details — from a public bootstrap endpoint, so nothing is hard-coded.
Fork the starter only when you want to go deeper than the brand: custom copy, page layout, fonts, or extra pages. Then it is your app and you make it fully your own.
Scope: this runtime branding is per platform deployment — one brand per hosted client.
Option 2 — build your own shell from the SDK
Prefer to build the portal inside your own app? Every portal function is an SDK piece — some are drop-in widgets, the rest are typed clients you render your own UI over. Here is the whole surface, part by part.
The shell
Wrap the app in BootstrapProvider (fetches the tenant's config by hostname) and PlatformAuthGate (blocks until the user is authenticated — the branded, no-IdP-page login; see Auth & the five gates). Inside, add the context providers the widgets read from — org, permissions and entitlements. The starter's App.tsx is the reference for the full provider stack.
import { BootstrapProvider } from '@egav/app-sdk';
import { PlatformAuthGate } from '@egav/app-sdk/auth';
export function App() {
return (
<BootstrapProvider>
<PlatformAuthGate>
{/* org / permissions / feature providers, then your routes */}
<YourPortalRoutes />
</PlatformAuthGate>
</BootstrapProvider>
);
}Every part, and what to reach for
| Portal function | SDK piece |
|---|---|
| Tenant config at boot | BootstrapProvider / useBootstrap — @egav/app-sdk |
| Login, session, logout | SessionManager, the cpHeadless* login helpers, AuthGate / PlatformAuthGate — @egav/app-sdk/auth |
| Sign-up + email verification | createSignupClient — @egav/signup (see Sign-up) |
| Subscriptions, invoices, plan changes | createSubscriptionsClient — @egav/billing (see Subscriptions) |
| Buy credits / one-off products | BuyCredits, ProductCatalog — @egav/billing |
| Notifications inbox | NotificationsWidget + createNotifyClient — @egav/app-sdk/widgets, /clients |
| Comments | CommentsWidget + createCommentsClient — @egav/app-sdk/widgets, /clients |
| Audit trail | AuditTrailWidget — @egav/app-sdk/widgets |
| Org settings / config UI | GroupEditor + renderPredefinedField + createConfigV2Client — see below |
| Lists / tables | DataTable — @egav/app-sdk/datatable |
| File attachments | createFilesClient (presigned up/download) — @egav/app-sdk/clients |
| Data export / import | createDataExchangeClient — @egav/app-sdk/clients |
| Feature / quota gating in the UI | FeatureGate, useFeature, useQuota — @egav/app-sdk/features |
| Permission gating | PermissionGate, usePermissions — @egav/app-sdk/permissions |
| AI chat | @egav/ai-chat |
| Profile, password change | no widget — call the service (see below) |
| Support tickets, knowledge base | no widget — call the service, or lift the starter page (see below) |
One ApiClient drives the widgets
The notifications, comments and audit widgets each take a single ApiClient. Build one with createApiClient — it resolves each service by name and attaches the bearer, refreshing it for you — then hand it to every widget (they render inside the shell's context + timezone providers):
import { createApiClient } from '@egav/app-sdk/clients';
import { NotificationsWidget, CommentsWidget, AuditTrailWidget } from '@egav/app-sdk/widgets';
const apiClient = createApiClient({ /* service URLs + token source, from bootstrap */ });
<NotificationsWidget apiClient={apiClient} />
<CommentsWidget apiClient={apiClient} contextType="ticket" contextId={ticketGuid} />
<AuditTrailWidget apiClient={apiClient} resourceType="org" resourceId={orgGuid} />Settings rendering — the config screen
The portal's Organization Settings screen is the manifest-driven config surface rendered in the browser, and it is the one non-obvious part. GroupEditor (@egav/app-sdk/settings-renderer) is headless — it owns the draft, the edit lock and the save, and delegates each control to a renderField you supply, so your design system stays in charge. renderPredefinedField (@egav/app-sdk/field-widgets) draws the platform's predefined types (platform:color@1, platform:image@1, pickers, …); wrap the tree in an OptionsSourceProvider bound to a createConfigV2Client so provider-backed pickers resolve.
import { GroupEditor } from '@egav/app-sdk/settings-renderer';
import { OptionsSourceProvider, renderPredefinedField } from '@egav/app-sdk/field-widgets';
import { createConfigV2Client } from '@egav/app-sdk/config-v2';
const configClient = createConfigV2Client({ /* CP base URL + token */ });
<OptionsSourceProvider client={configClient}>
<GroupEditor
group={group}
scopeId={orgGuid}
renderField={(props) => renderPredefinedField({ typeRef: props.field.type, ...props })}
/>
</OptionsSourceProvider>Faster still: the starter'sOrgConfigGroupEditor(org config) andMePreferencesGroupEditor(a user's own preferences) are ready antd skins over the same headlessGroupEditor/SelfScopeGroupEditor— copy one instead of styling from scratch.
Profile, password and support — bring your own UI
A few surfaces have no drop-in widget: profile, password change, support tickets and the knowledge base. Call the service with apiFetchWithAuth (it refreshes the token for you) and render your own form or list:
import { apiFetchWithAuth } from '@egav/app-sdk/auth';
// Post to the portal's password-change endpoint (see the starter for the exact path).
await apiFetchWithAuth(passwordChangeUrl, {
method: 'POST',
body: JSON.stringify({ current_password, new_password }),
});Or the pragmatic shortcut: lift that page from the starter (Option 1). Its ProfilePage, support and knowledge-base screens are already built against these same clients — copying one page beats rebuilding it.
The exact profile endpoints — GET/PUT /v1/control-plane/portal/me with the full field schema, the members list, and the org-side reads — are documented in Users & organizations.
Which to choose
- Want a working portal fast, with a shell you do not maintain? Run the ready-made portal — brand it from the Control Plane, fork only for deeper changes.
- Already have an app and want billing, support or auth inside it? Build it from the SDK — drop-in widgets where they exist, typed clients everywhere else.
Either way the data layer is the same platform services, and the auth model is the branded, no-IdP-page flow described in Auth & the five gates. The full customer journey that lands a user here is sign-up then subscriptions.