Mobile & screens

Mobile dashboard

okta-app's first screen is a dashboard, not an app list. The launcher has a tab of its own — Apps, in the bottom bar — and the dashboard shows what the installed apps themselves have to say: today's numbers, the queue, what just happened.

Your app can put a block on it. Every mode may — native, webview and external alike — because a block is data, not a surface.

The portals get the same home

The student's and the guardian's portal homes are dashboards too, with the portal launcher on its own Apps tab — the same shape the tenant home has. Your block reaches a portal automatically wherever your app declares an audience for it; nothing new to declare, and audiences narrows there exactly as it does for roles — write ["guardian"] to brief guardians only.

Two things differ, both by design:

  • The audience parameter carries the portal. Your provider's first argument (and the role field of the external POST) is student or guardian where a tenant call passes the role key. Same parameter, same meaning — who is reading — and a provider that ignores it briefs every audience the same figures, which is a fine default.
  • The portal is cross-tenant, so your block appears once per school. A guardian with children at two schools that installed your app gets two blocks — the figures differ, and okta-app labels each with its school's name. You do nothing for this: your provider runs once per school in that school's context, exactly as it always has.

The rule everything else follows: data, not design

You return figures and labels; okta-app draws them, identically for every app. That is not a limitation to work around — it is the reason a dashboard carrying five apps still reads as one screen. A block that brought its own layout would be a partner's card sitting inside our dashboard, and the reader's first question — who is telling me this, and how old is this number? — would get five different answers.

It is also why a block costs nothing: no mini-app compile, no sandbox, no contract version. Five blocks are five array returns, not five interpreters.

Declaring it

Inside mobile in the manifest:

"mobile": {
  "supported": true,
  "mode": "native",
  "entry": "main",
  "dashboard": {
    "enabled": true,
    "title": "حضور اليوم",
    "title_en": "Today's attendance",
    "provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
    "audiences": ["tenant-admin", "teacher"],
    "cache_ttl": 300
  }
}
Key Required Description
enabled Must be true. Do not ship a disabled block — omit the whole thing.
title / title_en (one of) The block's heading. Without it the card is headed by your slug, which tells the person holding the phone nothing.
provider embedded A class under Modules\ implementing MobileDashboardProvider.
endpoint external An https URL that receives one signed POST.
audiences — Role keys. Narrows, never widens (see below).
cache_ttl — Seconds. Clamped to 30..3600.
portal_scope — per_tenant (default) or combined — how the block reads on a portal home when one person meets several schools.
mode — Who draws the inside: data (default — the host draws your figures) or native (your Dart draws it — see below).
entry with native The card's Dart entry file under okta_app/native/<package>/lib/.
min_contract with native The okta-app host contract the card needs. Floor 21; lower values are clamped up.

💡 Instead of editing manifest.json by hand: the MCP tool set_mobile_dashboard writes the block straight onto an editable version and runs the publish checks immediately; get_mobile_surface reads it back. The repo sync (sync_from_manifest) carries it too.

The transport follows integrationType and is not declared. An embedded app names a class the platform calls in-process; an external one names a URL the platform signs one request to. Declaring both — or the wrong one — is rejected at publish, because a second switch for something the manifest already answers means the first app to set them inconsistently publishes a block that never appears.

Embedded: implement the contract

namespace Modules\OktaHdor\App\Services;

use App\Services\PartnerApi\Contracts\MobileDashboardProvider;

final class DashboardProvider implements MobileDashboardProvider
{
    public function dashboard(string $roleKey, string $locale): array
    {
        $today = Attendance::query()->whereDate('taken_at', today());

        return [
            'stats' => [
                [
                    'label' => $locale === 'en' ? 'Present' : 'حاضر',
                    'value' => number_format($today->clone()->where('status', 'present')->count()),
                    'tone'  => 'success',
                ],
                [
                    'label' => $locale === 'en' ? 'Absent' : 'غائب',
                    'value' => number_format($today->clone()->where('status', 'absent')->count()),
                    'tone'  => 'danger',
                ],
            ],
            'rows' => [
                ['title' => 'Year 1', 'subtitle' => 'A', 'value' => '98%'],
            ],
            'open_label' => $locale === 'en' ? 'Open register' : 'فتح السجل',
        ];
    }
}

It runs inside your own module context, reading your schema with your credentials exactly as your own pages do. Scope every query to the current tenant.

External: receive a signed POST

POST <endpoint>
X-Okta-Timestamp: 1755859200
X-Okta-Signature:  HMAC-SHA256( "<timestamp>.<body>", install_signing_secret )

{"tenant_id": 42, "role": "tenant-admin", "locale": "en"}

Verify the signature before answering, and answer in the same array shape. The timeout is 6 seconds; past it you are treated as unreachable.

Nothing identifying the person is sent. A figure on the dashboard is about the school, and you do not need to know which member of staff is looking at their phone to count today's absences.

The payload

[
    'title'      => "Today's attendance", // optional — overrides the manifest title
    'stats'      => [ ['label' =>, 'value' =>, 'caption' => ?, 'tone' => ?] ],
    'rows'       => [ ['title' =>, 'subtitle' => ?, 'value' => ?, 'tone' => ?] ],
    'open_label' => 'Open register',      // optional — omit to show no button
    'message'    => null,                 // a note shown with the figures
]

value is a string you have already formatted. Percentages, currencies, ratios and counts share no formatter, you know which you are producing, and a numeric field would force the client to guess — or carry a format vocabulary that grows with every partner.

tone is a meaning, not a colour: neutral | success | warning | danger | info. The host resolves it to a palette that matches the rest of the screen in both light and dark; a hex you pick would match neither.

Limits are enforced: 6 stats, 8 rows, 120 characters per string. The excess is truncated silently — a block that takes the whole screen is not a block.

Keys outside the vocabulary are dropped, never passed through. An unrecognised key that reached the client would become a de-facto part of the contract the first time a partner relied on it.

Narrowing by role: it narrows, never widens

audiences are role keys that restrict the block to some of the people who can already see your app. The dashboard is built from the catalog the user already receives, so an app hidden from a role cannot brief that role through the back door. Leave it empty for every role that can see the app.

Failure is a state, not an exception

Throw when you fail. The platform catches it, logs it, and shows your block offline — or with its previous figures marked stale if any were cached.

Do not return an empty array instead. Empty is indistinguishable from "there is genuinely nothing today", and would show an operator a confident zero on a morning your query was broken.

The platform also keeps a day-long fallback copy to answer with when you cannot: yesterday's attendance marked "not current" is more use to someone standing at a school gate than an empty box.

Drawing the card yourself — mode: "native"

Everything above holds — and then an embedded app can go one step further and draw the inside of its card in real Dart, through the same source-on-device runtime its mini-app already uses:

"dashboard": {
  "enabled": true,
  "title": "حضور اليوم",
  "title_en": "Today's attendance",
  "provider": "Modules\\OktaHdor\\App\\Services\\DashboardProvider",
  "mode": "native",
  "entry": "okta_app/native/card/lib/main.dart",
  "min_contract": 21
}

The host keeps the frame. The corners, the header with your app's name and icon, the sync chip and the "open" button stay the platform's; your widget owns the inside of a box whose edges it cannot reach — including its height: the card is clipped at 320pt, not scrolled, because a scrollable inside a dashboard that itself scrolls steals the drag from the reader.

provider stays required, and that is the design, not a leftover. The figures it returns are the card an older phone draws — and the card this phone draws the moment anything goes wrong. Every failure lands there, silently, in the same session: source that will not compile, a widget that throws while building, a first frame that does not arrive within 500ms of the runtime handing over, a device whose host contract is below min_contract. There is no partner-visible error state on the home screen — the reader gets your data card, and the home screen never breaks because of a partner. Test your card by making it throw on purpose: what you should see is your own figures.

  • entry is a package of its own. It lives under okta_app/native/<package>/lib/ like every native entry, and should be a dedicated package (okta_app/native/card/) rather than a second file inside your app's package — delivery slices one package per entry, so a card entry inside the app's package would download and compile your whole app to draw one card.
  • min_contract is required and 21 is the floor — 21 is where the card runtime shipped, so lower values are clamped up. A device below it never mounts the card and shows the data card instead; that refusal is deliberate and invisible, not an error.
  • The sandbox is the mini-app's sandbox. Same Okta.* API, same isolation, same theme. One symbol exists for cards specifically: Okta.openMiniApp() opens your full app — the same act as the host's own "open" button. Deep navigation stays in the app; a card is a summary, not a screen.
  • Portals multiply the card, not its state. A guardian with children at two schools gets two cards, compiled and mounted separately, sharing no state, no tenant and no delegate — the same per-school isolation the full mini-app has.
  • Embedded only. A native card compiles from your signed source bundle, and an external app ships no code — mode: "native" is refused at publish; keep mode: "data" with your endpoint.

set_mobile_dashboard writes all three keys and runs these checks at dev time; the manifest schema and the publish gate repeat them.

Before you publish

  • Your block appears only to people who can already see your app in the catalog.
  • cache_ttl is clamped to 30..3600 at both ends — the published manifest shows the number that will actually be used, not the one you wrote.
  • provider must live under Modules\. The platform constructs this class by name, so anything outside your module namespace is a manifest choosing a platform internal to instantiate — and the instanceof check is too late, because the constructor has already run.
  • Nothing in a block is tappable except one button that opens your app. No links, no HTML, no images.

View the whole guide on one page · ↧ Raw Markdown