Building your app
App Control Panel
The developer builds their app's control panel inside the app code; the platform owns the design + access controls and opens it from the partner portal via a signed link. Each app has two panels — one per environment (Production and Sandbox) — based on the version published/installed in each. On top of what you build yourself, the platform automatically adds an "App Data" section to every panel's chrome — an isolated key/value store for your app that you manage with zero code (see App Data below).
How it works
- Declare the panel in the manifest via a
developer_uiblock. - Two cards appear on the portal's Integration tab: Production and Sandbox. Each is enabled only when a version is live in that environment and the panel is declared in the manifest.
- The "Open panel" button hits a lightweight okta-partners endpoint
(
GET /partner/modules/{slug}/dev-panel/{env}) that mints a short-lived JWT (5 min) and redirects you to the panel page on the target okta-web environment:<web base>/partner-dev/{slug}/panel?token=<jwt>. - The JWT is HS256-signed with the same shared bridge secret each
environment's outbound bridge calls already use (production ⇒
outboundToken, sandbox ⇒sandboxOutboundToken) — no new secret. Claims:{ iss:"okta-partners", aud:"okta-web-devpanel", sub:<partner user id>, module:<slug>, env:"production"|"sandbox", iat, exp:iat+300, jti }.
Manifest block
For any integration type (embedded / payment / notification / external):
"developer_ui": {
"has_developer_page": true,
"livewire_component": "my-app-developer-panel"
}
- embedded / payment / notification ⇒ set
livewire_component(a Livewire component alias rendered inside okta-web; pattern^[a-z0-9][a-z0-9-]*(::|-)[a-z0-9-.]+$— use the hyphen form, e.g.my-app-developer-panel; a::alias is not resolvable by Livewire 4). - external ⇒ set
urlinstead (an HTTPS URL you host on your own environment — your environments are your own, so you get one card that opens the URL directly in a new tab):
"developer_ui": {
"has_developer_page": true,
"url": "https://partner.example/console"
}
The two are mutually exclusive (XOR): the manifest emits only the key matching the integration type. The block is overridable per version from the Integration tab; a blank value inherits the module-level value.
Panel sections (sections)
Who owns what — everything below follows from this split:
| The platform owns | You own |
|---|---|
| The panel shell (chrome) and outer frame | The content of your own sections |
| The header, app name and app icon | One Livewire component per section |
| The section navigation and its order | Each section's label and icon (from the platform's list) |
| The built-in sections (App Data, health, ...) | — |
The platform fixes the built-in sections in every panel; sections is
your half: app-specific sections hung off the platform's navigation, each
rendering one of your Livewire components inside the shell. The key is
optional — omitting it gives you a single-section panel, exactly the
behaviour that existed before the key did.
"developer_ui": {
"has_developer_page": true,
"livewire_component": "my-app-developer-panel",
"sections": [
{ "key": "trips", "label": "الرحلات", "label_en": "Trips",
"icon": "map", "livewire_component": "my-app-trips" },
{ "key": "drivers", "label": "السائقون", "label_en": "Drivers",
"icon": "users", "livewire_component": "my-app-drivers" }
]
}
| Field | Required | Rule |
|---|---|---|
key |
^[a-z][a-z0-9-]*$ — must start with a lowercase letter (never a digit), then lowercase letters/digits/hyphens, no underscore, max 32 chars, and unique within the array (the panel navigation is keyed by it). |
|
label |
Max 60 chars (the Arabic/default-locale label in the navigation). | |
label_en |
Max 60 chars. | |
livewire_component |
The same alias rule as the panel's own component — and :: is flattened the same way, because Livewire 4 cannot resolve a name containing ::. |
|
icon |
One of: home, chart, list, map, settings, bell, users, box, calendar, file. |
- Maximum 10 sections; array order is navigation order.
- The
keyrule here is deliberately narrower than what okta-web accepts (^[a-z][a-z0-9_-]{0,39}$): okta-partners produces the manifest and okta-web consumes it, so the producer is the stricter side. A key we let through but okta-web rejects would be a publish failure caused by data we waved through. - External apps have no
sections: they host the whole panel themselves, so there is no platform shell to hang sections off — the key is dropped exactly aslivewire_componentis. - A half-valid block never ships: a section missing or malforming
key,labelorlivewire_componentis dropped whole — a nav entry that routes nowhere is worse than a missing one. A duplicatekeykeeps only its first occurrence, an unrecognised icon is dropped silently (the section stays, iconless — decoration never fails a declaration), and the list is capped at 10.
Panel design policy
The panel lives inside a shell the platform owns, and every rule below follows from that. All of them are literal:
- Do not build a header or a nav bar inside your section. The shell already provides both; anything you add renders duplicated above the platform's own header.
<x-…>components only. No raw<button>/<input>/<select>/<textarea>/<table>— the visual identity lives inside the components.- No fixed colours such as
gray/zinc/slate/indigo; use the platform tokens (neutral,primary,success,warning,danger). - No
max-w-*. The shell enforces the width; adding it breaks the grid and clips long content. - The section's name comes from
label/label_en, not from the blade — the platform draws the title in the navigation. - Icons come from the allowed list only; never ship your own SVG into the navigation.
Rules 2–4 are enforced by the UiScanner design scanner and the CI gate over
every *.blade.php in your repo, so a violation never reaches production.
The controls
- Design: the panel blade goes through the same UiScanner / CI gate as the
rest of your pages —
<x-…>components + platform tokens only, no raw HTML, nogray/zinc/slate/indigo, nomax-w-*. - Access: platform-issued signed link only. The developer context has no
tenant — you see your app's aggregate health across all installs, never a
single tenant's data. Your panel component can only reach one namespace:
App\Services\PartnerApi\DeveloperPanel\*— the read-only aggregates (GetInstallsCount, ...) plus App Data CRUD underDeveloperPanel\Settings\*(see the next section).
Full example
Livewire component (shipped in the boilerplate under
app/DeveloperPanel/Panel.php.example):
namespace Modules\MyApp\app\DeveloperPanel;
use App\Services\PartnerApi\DeveloperPanel\GetInstallsCount;
use Livewire\Component;
class Panel extends Component
{
// The only two inputs the platform passes — never a tenant id.
public string $moduleSlug = '';
public string $environment = 'production';
public function render(): \Illuminate\View\View
{
$installs = app(GetInstallsCount::class)($this->moduleSlug);
return view('my-app::developer-panel.panel', [
'installs' => $installs,
]);
}
}
The blade uses platform components only:
<div class="space-y-6">
<x-badge :color="$environment === 'production' ? 'success' : 'warning'" variant="soft">
{{ $environment }}
</x-badge>
<x-card>
<div class="p-4">
<p class="text-3xl font-bold text-[var(--color-neutral-900)]">{{ $installs }}</p>
</div>
</x-card>
</div>
Available stats
The App\Services\PartnerApi\DeveloperPanel\* read surface on okta-web exposes
aggregates across all of your app's installs (never a single tenant's rows) —
e.g. installs count (GetInstallsCount), active installs, and error counts.
Any payment charge surfaced in these aggregates is called a "دفعة".
App Data
A key/value (JSON) table scoped strictly to your app — a low-friction way to store operational settings/flags you want to tune between releases without a new publish.
- Zero-code manager UI: an "App Data" section appears automatically inside the panel chrome — every app gets full CRUD (add/edit/delete a key) with no code of your own.
- Two service surfaces:
- From your own panel component (namespace allowed only in the panel
context):
App\Services\PartnerApi\DeveloperPanel\Settings\{ListSettings, PutSetting, DeleteSetting}— each takes your app'smoduleSlug. - From your app's embedded code at runtime, in front of tenants
(read-only, no writes):
App\Services\PartnerApi\AppSettings\ {GetAppSetting, GetAppSettings}— cached for ~60 seconds.
- From your own panel component (namespace allowed only in the panel
context):
- Limits: 100 keys max per app, each value ≤ 64KB, key format
^[a-z][a-z0-9_.-]{0,119}$. - Isolation: fully server-enforced — the panel session context is derived
only from the verified JWT (
iss=okta-partners,aud=okta-web-devpanel); your app's session can never touch another app's data. - The pattern: tune your app's behavior (feature flags, limits, provider parameters, display texts) from the panel between releases — no republish needed.
Runtime read from your app's code (e.g. inside a service under
App\Services\PartnerApi\*):
use App\Services\PartnerApi\AppSettings\GetAppSetting;
$maxItems = app(GetAppSetting::class)('max_items_per_page', 20);
Write from an action inside your panel component:
use App\Services\PartnerApi\DeveloperPanel\Settings\PutSetting;
public function saveMaxItems(): void
{
app(PutSetting::class)($this->moduleSlug, 'max_items_per_page', (string) $this->maxItems);
$this->dispatch('saved');
}