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

  1. Declare the panel in the manifest via a developer_ui block.
  2. 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.
  3. 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>.
  4. 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 url instead (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 key rule 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 as livewire_component is.
  • A half-valid block never ships: a section missing or malforming key, label or livewire_component is dropped whole — a nav entry that routes nowhere is worse than a missing one. A duplicate key keeps 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:

  1. 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.
  2. <x-…> components only. No raw <button> / <input> / <select> / <textarea> / <table> — the visual identity lives inside the components.
  3. No fixed colours such as gray / zinc / slate / indigo; use the platform tokens (neutral, primary, success, warning, danger).
  4. No max-w-*. The shell enforces the width; adding it breaks the grid and clips long content.
  5. The section's name comes from label/label_en, not from the blade — the platform draws the title in the navigation.
  6. 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, no gray/zinc/slate/indigo, no max-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 under DeveloperPanel\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:
    1. From your own panel component (namespace allowed only in the panel context): App\Services\PartnerApi\DeveloperPanel\Settings\{ListSettings, PutSetting, DeleteSetting} — each takes your app's moduleSlug.
    2. 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.
  • 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');
}

View the whole guide on one page · ↧ Raw Markdown