Skip to main content

Migration WebView — iOS app handoff

This describes what the iOS app must implement to give its users the enode portal's data-migration flow, even though those users can't access the portal normally (their license doesn't grant portal access). The portal side is already built — the route /migrate (see apps/portal/src/app/migrate/). This document is the contract the app implements against.

Goal

Users of the iOS app need the portal's migration flow (migrate data from another system into their enode account) but are blocked from the portal: the backend rejects a license-mismatched account at portal login with HTTP 314.

Solution: open the portal's dedicated, unlinked /migrate route inside an in-app WebView and inject the user's existing bearer token so no login happens — which also sidesteps the 314 gate, because the user arrives already authenticated (314 is a login-time check).

The entire migration UI lives in the web route. The app builds only the entry point, the WebView, and the token injection — no migration screens.

What the app must do

  1. An entry point somewhere appropriate (settings / onboarding — the app team's call) that opens the migration WebView modally.

  2. A WKWebView — it MUST be WKWebView, not SFSafariViewController (SFSafariViewController can't run injected JavaScript). Load:

    • https://<PORTAL_HOST>/migrate/trailing slash required (the portal is a static export with trailingSlash: true).
    • Get <PORTAL_HOST> per environment (dev / staging / prod) from the portal team.
  3. Native token injection via a WKUserScript at .atDocumentStart (runs before the web app's own JavaScript), setting a known global:

    // `token` = the user's current bearer JWT — the exact value the app already
    // sends as `Authorization: Bearer <token>` on its own API calls.
    let js = "window.__ENODE_MIGRATION_TOKEN__ = \"\(token)\";"
    let script = WKUserScript(source: js,
    injectionTime: .atDocumentStart,
    forMainFrameOnly: true)

    let controller = WKUserContentController()
    controller.addUserScript(script)

    let config = WKWebViewConfiguration()
    config.userContentController = controller
    // Ephemeral store: the injected token / the portal's localStorage do not
    // persist after the WebView closes.
    config.websiteDataStore = .nonPersistent()

    let webView = WKWebView(frame: .zero, configuration: config)
    webView.load(URLRequest(url: migrateURL))

    (The token is a base64url JWT — no quotes/newlines — but still build the JS string carefully.)

The contract (must match the portal exactly)

ItemValue
Injected globalwindow.__ENODE_MIGRATION_TOKEN__
Valuethe user's current bearer JWT
Injection timingWKUserScript at .atDocumentStart (before the web app's JS)
URLhttps://<PORTAL_HOST>/migrate/

The portal reads the global on mount, stores it, deletes the global, then runs the flow. If the global is absent (and no token is otherwise stored) the portal shows an "open migration from the app" message — so injection MUST happen before first paint, which .atDocumentStart guarantees.

Optional — auto-dismiss on completion

If you want the app to close the WebView automatically (instead of the user closing it manually), add a WKScriptMessageHandler named enodeMigration. The portal posts this when the flow ends (or is cancelled):

window.webkit.messageHandlers.enodeMigration.postMessage({ event: "close" });
controller.add(self, name: "enodeMigration") // on the same WKUserContentController

func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
if message.name == "enodeMigration",
let body = message.body as? [String: Any],
body["event"] as? String == "close" {
// dismiss the WebView
}
}

For v1 this is optional — without the handler the user just closes the WebView.

What the portal side handles (so you know the boundary)

  • Reads the injected token and runs the full migration wizard: connect the source account (its own email + OTP, inside the wizard), select people / groups / timeframe, map exercises, confirm, and start the import.
  • Session expiry: a 401 mid-flow shows a "session expired — reopen from the app" message; it does NOT redirect to the portal login (which these users can't pass). So a token that expires mid-migration fails gracefully — the app just needs to let the user reopen the WebView with a fresh token.
  • You implement no migration UI, OTP handling, or API calls — only the WebView + token injection.

Security notes

  • The token is injected as a JS global — it never appears in a URL, query string, server access log, or Referer header.
  • Use a non-persistent WKWebsiteDataStore so the token and the portal's localStorage are cleared when the WebView closes.
  • HTTPS only; ensure ATS allows <PORTAL_HOST>.
  • No new secret is introduced — it's the user's existing session token.

Open coordination items (NOT iOS work — tracked by the portal/backend team)

  1. The exact /migrate URL per environment (dev / staging / prod).
  2. Backend confirmation that a non-portal account's token succeeds against all migration endpoints — i.e. the flavor/314 gate is login-time only (the portal-client evidence indicates it is). If the backend turns out to gate per request, the portal would additionally send a migration-flavor api-key; either way the app-side contract above is unaffected.
    • Nuance to raise with the backend team: the iOS token was minted under the iOS (pro) api-key, while the portal WebView sends the portal api-key — confirm the backend does not require the bearer token's flavor to match the api-key header's flavor on each request.

First steps for the iOS engineer

  1. Find how the app currently stores/reads its bearer token, and how it presents modal WebViews (if at all).
  2. Confirm the /migrate URL per environment with the portal team.
  3. Implement the entry point + WKWebView + .atDocumentStart injection above, then (optionally) the enodeMigration message handler for auto-dismiss.