From 2555817a72f9f5dc2377da1503356eff99c4d4d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:39:53 +0000 Subject: [PATCH 1/8] Add Expo config plugin, multi-region, getOrRefreshAccessToken and TurboModule support Expo config plugin generates the iOS and Android native setup on prebuild, including multi-region via Frontegg.plist and FRONTEGG_REGIONS. Exposes getRegions, initWithRegion and selectedRegion, adds getOrRefreshAccessToken, and supports the new architecture through a codegen spec with old and new arch module implementations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LEp5Aemy81i5PZmfV7iX4t --- android/build.gradle | 37 +- android/src/main/AndroidManifestNew.xml | 17 + .../reactnative/AccessTokenRefresher.kt | 58 + .../frontegg/reactnative/FronteggConstants.kt | 3 +- .../frontegg/reactnative/FronteggRNModule.kt | 74 +- .../frontegg/reactnative/FronteggRNPackage.kt | 31 +- .../java/com/frontegg/reactnative/Parser.kt | 11 + .../reactnative/RegionsInitializer.kt | 44 + .../java/com/frontegg/reactnative/Utils.kt | 3 + android/src/newarch/FronteggRNSpec.kt | 6 + android/src/oldarch/FronteggRNSpec.kt | 42 + app.plugin.js | 1 + docs/README.md | 13 + docs/advanced.md | 72 + docs/api.md | 22 + docs/setup.md | 90 + docs/usage.md | 31 + ios/FronteggRN.m | 89 - ios/FronteggRN.mm | 120 + ios/FronteggRN.swift | 141 +- ios/FronteggRN.xcodeproj/project.pbxproj | 8 +- package.json | 23 +- plugin/src/__tests__/android.test.ts | 165 ++ plugin/src/__tests__/ios.test.ts | 196 ++ plugin/src/android.ts | 251 ++ plugin/src/config.ts | 83 + plugin/src/index.ts | 23 + plugin/src/ios.ts | 331 +++ plugin/tsconfig.json | 17 + src/FronteggContext.ts | 3 + src/FronteggNative.ts | 40 +- src/NativeFronteggRN.ts | 64 + src/__tests__/index.test.tsx | 78 +- src/index.tsx | 4 + tsconfig.build.json | 2 +- yarn.lock | 2371 ++++++++++++++++- 36 files changed, 4340 insertions(+), 224 deletions(-) create mode 100644 android/src/main/AndroidManifestNew.xml create mode 100644 android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt create mode 100644 android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt create mode 100644 android/src/newarch/FronteggRNSpec.kt create mode 100644 android/src/oldarch/FronteggRNSpec.kt create mode 100644 app.plugin.js delete mode 100644 ios/FronteggRN.m create mode 100644 ios/FronteggRN.mm create mode 100644 plugin/src/__tests__/android.test.ts create mode 100644 plugin/src/__tests__/ios.test.ts create mode 100644 plugin/src/android.ts create mode 100644 plugin/src/config.ts create mode 100644 plugin/src/index.ts create mode 100644 plugin/src/ios.ts create mode 100644 plugin/tsconfig.json create mode 100644 src/NativeFronteggRN.ts diff --git a/android/build.gradle b/android/build.gradle index 77c2f9c..f5310d6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -18,6 +18,14 @@ def isNewArchitectureEnabled() { return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" } +def supportsNamespace() { + def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.') + def major = parsed[0].toInteger() + def minor = parsed[1].toInteger() + + return (major == 7 && minor >= 3) || major >= 8 +} + apply plugin: "com.android.library" apply plugin: "kotlin-android" @@ -37,6 +45,16 @@ def getExtOrIntegerDefault(name) { } android { + if (supportsNamespace()) { + namespace "com.frontegg.reactnative" + + sourceSets { + main { + manifest.srcFile "src/main/AndroidManifestNew.xml" + } + } + } + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") defaultConfig { @@ -44,6 +62,10 @@ android { targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() } + buildFeatures { + buildConfig true + } + buildTypes { release { minifyEnabled false @@ -68,6 +90,19 @@ android { useJUnit() } } + + sourceSets { + main { + if (isNewArchitectureEnabled()) { + java.srcDirs += [ + "src/newarch", + "${project.buildDir}/generated/source/codegen/java" + ] + } else { + java.srcDirs += ["src/oldarch"] + } + } + } } repositories { @@ -93,7 +128,7 @@ dependencies { if (isNewArchitectureEnabled()) { react { jsRootDir = file("../src/") - libraryName = "ReactNative" + libraryName = "RNFronteggRNSpec" codegenJavaPackageName = "com.frontegg.reactnative" } } diff --git a/android/src/main/AndroidManifestNew.xml b/android/src/main/AndroidManifestNew.xml new file mode 100644 index 0000000..963e675 --- /dev/null +++ b/android/src/main/AndroidManifestNew.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt b/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt new file mode 100644 index 0000000..601de9c --- /dev/null +++ b/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt @@ -0,0 +1,58 @@ +package com.frontegg.reactnative + +import com.frontegg.android.FronteggAuth +import com.frontegg.android.utils.JWTHelper +import java.io.IOException +import java.time.Instant +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull + +private const val MINIMUM_ACCESS_TOKEN_VALIDITY_SECONDS = 15 +private const val AUTH_STATE_SETTLE_TIMEOUT_MS = 20_000L +private const val AUTH_STATE_SETTLE_POLL_INTERVAL_MS = 200L + +// Mirrors iOS getOrRefreshAccessTokenAsync() using only the pinned Android SDK's public API. +internal suspend fun fetchValidAccessToken(auth: FronteggAuth): String? { + val settled = withTimeoutOrNull(AUTH_STATE_SETTLE_TIMEOUT_MS) { + while (isAuthStateSettling(auth)) { + delay(AUTH_STATE_SETTLE_POLL_INTERVAL_MS) + } + } + if (settled == null) { + throw IllegalStateException("Timed out waiting for authentication state to settle") + } + + if (auth.refreshToken.value == null) { + return null + } + + val currentAccessToken = auth.accessToken.value + if (currentAccessToken != null && hasMoreThanMinimumValidity(currentAccessToken)) { + return currentAccessToken + } + + if (auth.refreshTokenAndWait()) { + return auth.accessToken.value + } + if (auth.refreshToken.value == null) { + return null + } + if (auth.isOfflineMode.value) { + return auth.accessToken.value + } + throw IOException("Access token could not be refreshed") +} + +private fun isAuthStateSettling(auth: FronteggAuth): Boolean { + val loadingOnline = auth.isLoading.value && !auth.isOfflineMode.value + return auth.initializing.value || auth.refreshingToken.value || loadingOnline +} + +private fun hasMoreThanMinimumValidity(accessToken: String): Boolean { + return try { + val secondsLeft = JWTHelper.decode(accessToken).exp - Instant.now().epochSecond + secondsLeft > MINIMUM_ACCESS_TOKEN_VALIDITY_SECONDS + } catch (e: Exception) { + false + } +} diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt b/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt index f7fc392..82e4b40 100644 --- a/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt +++ b/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt @@ -6,7 +6,8 @@ data class FronteggConstants( val applicationId: String?, val useAssetsLinks: Boolean, val useChromeCustomTabs: Boolean, - val bundleId: String + val bundleId: String, + val regionsJson: String? = null, ) { fun toMap(): MutableMap { return mutableMapOf( diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt b/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt index 65faa9a..1f1855e 100644 --- a/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt +++ b/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt @@ -6,13 +6,13 @@ import android.os.Looper import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap import com.facebook.react.common.LifecycleState import com.facebook.react.modules.core.DeviceEventManagerModule import com.frontegg.android.AdminPortalActivity +import com.frontegg.android.fronteggApp import com.frontegg.android.exceptions.CanceledByUserException import com.frontegg.android.exceptions.FailedToAuthenticateException import com.frontegg.android.fronteggAuth @@ -31,7 +31,7 @@ import kotlin.time.toDuration class FronteggRNModule(val reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { + FronteggRNSpec(reactContext) { private val fronteggConstants: FronteggConstants private var disposable: Disposable? = null @@ -43,6 +43,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : init { fronteggConstants = reactContext.fronteggConstants + initRegionsFromBuildConfig(reactContext.applicationContext, fronteggConstants) // SDK 1.3+ auto-initializes from BuildConfig when first accessing context.fronteggAuth } @@ -60,7 +61,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : // https://reactnative.dev/docs/native-modules-android @ReactMethod - fun subscribe() { + override fun subscribe() { if (this.disposable != null) { this.disposable!!.dispose() } @@ -111,6 +112,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : putBoolean("isLoading", isLoading) putBoolean("initializing", initializing) putBoolean("showLoader", showLoader) + putMap("selectedRegion", auth.selectedRegion?.toWritableMap()) } @@ -118,7 +120,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun logout(promise: Promise) { + override fun logout(promise: Promise) { // Resolve only after the SDK reports the logout finished, so JS can // await the actual end of the session (parity with the iOS bridge). // @@ -142,17 +144,17 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun addListener(eventName: String?) { + override fun addListener(eventName: String?) { } @ReactMethod - fun removeListeners(count: Int) { + override fun removeListeners(count: Double) { } @ReactMethod - fun login(loginHint: String?, promise: Promise) { + override fun login(loginHint: String?, promise: Promise) { withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity -> auth.login(activity, loginHint) { error -> resolveOrRejectLogin(error, promise) @@ -166,7 +168,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : * [login] when this method is absent, losing the theming rather than the sign-in. */ @ReactMethod - fun loginWithOptions(loginHint: String?, customization: ReadableMap?, promise: Promise) { + override fun loginWithOptions(loginHint: String?, customization: ReadableMap?, promise: Promise) { withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity -> applyLoginBoxCustomization(customization) auth.login(activity, loginHint) { error -> @@ -176,7 +178,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun isLoginBoxCustomizationSupported(promise: Promise) { + override fun isLoginBoxCustomizationSupported(promise: Promise) { promise.resolve(LoginBoxCustomization.isSupported()) } @@ -193,14 +195,14 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun switchTenant(tenantId: String, promise: Promise) { + override fun switchTenant(tenantId: String, promise: Promise) { auth.switchTenant(tenantId) { success -> resolveTenantSwitch(success, tenantId, promise) } } @ReactMethod - fun directLoginAction( + override fun directLoginAction( type: String, data: String, ephemeralSession: Boolean, @@ -222,7 +224,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun refreshToken(promise: Promise) { + override fun refreshToken(promise: Promise) { // FR-25937: refreshTokenIfNeeded() starts the refresh in the background and returns // immediately, so resolving here handed JS a stale token. refreshTokenAndWait() suspends // until the refresh finishes; resolve its Boolean result to match iOS (which awaits a Bool). @@ -237,7 +239,31 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun loginWithPasskeys(promise: Promise) { + override fun getOrRefreshAccessToken(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + promise.resolve(fetchValidAccessToken(auth)) + } catch (e: Exception) { + promise.reject("GET_OR_REFRESH_ACCESS_TOKEN_ERROR", e.message, e) + } + } + } + + @ReactMethod + override fun initWithRegion(regionKey: String, promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + reactContext.applicationContext.fronteggApp.initWithRegion(regionKey) + notifyChanges() + promise.resolve(null) + } catch (e: Exception) { + promise.reject("INVALID_REGION", e.message, e) + } + } + } + + @ReactMethod + override fun loginWithPasskeys(promise: Promise) { withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity -> auth.loginWithPasskeys(activity) { error -> if (error != null) { @@ -250,14 +276,14 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun isSteppedUp(maxAgeSeconds: Double, promise: Promise) { + override fun isSteppedUp(maxAgeSeconds: Double, promise: Promise) { val maxAge = if (maxAgeSeconds < 0) null else maxAgeSeconds.toDuration(DurationUnit.SECONDS) promise.resolve(auth.isSteppedUp(maxAge)) } @ReactMethod - fun stepUp(maxAgeSeconds: Double, promise: Promise) { + override fun stepUp(maxAgeSeconds: Double, promise: Promise) { val activity = reactApplicationContext.currentActivity if (activity == null) { promise.reject("NO_ACTIVITY", "Current activity is null") @@ -275,7 +301,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun requestAuthorize(refreshToken: String, deviceTokenCookie: String?, promise: Promise) { + override fun requestAuthorize(refreshToken: String, deviceTokenCookie: String?, promise: Promise) { try { auth.requestAuthorize(refreshToken, deviceTokenCookie) { result -> result.fold( @@ -295,7 +321,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : @ReactMethod - fun registerPasskeys(promise: Promise) { + override fun registerPasskeys(promise: Promise) { withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity -> auth.registerPasskeys(activity) { error -> if (error != null) { @@ -308,7 +334,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun openAdminPortal(promise: Promise) { + override fun openAdminPortal(promise: Promise) { val activity = reactApplicationContext.currentActivity if (activity == null) { promise.reject("NO_ACTIVITY", "Cannot open Admin Portal without an active Activity") @@ -320,19 +346,19 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun loadEntitlements(forceRefresh: Boolean, promise: Promise) { + override fun loadEntitlements(forceRefresh: Boolean, promise: Promise) { auth.loadEntitlements(forceRefresh) { success -> promise.resolve(success) } } @ReactMethod - fun getFeatureEntitlement(key: String, promise: Promise) { + override fun getFeatureEntitlement(key: String, promise: Promise) { promise.resolve(entitlementToMap(auth.getFeatureEntitlements(key))) } @ReactMethod - fun getPermissionEntitlement(key: String, promise: Promise) { + override fun getPermissionEntitlement(key: String, promise: Promise) { promise.resolve(entitlementToMap(auth.getPermissionEntitlements(key))) } @@ -342,7 +368,11 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : putString("justification", entitlement.justification) } - override fun getConstants(): MutableMap = fronteggConstants.toMap() + override fun getTypedExportedConstants(): Map = + fronteggConstants.toMap().apply { + put("isRegional", auth.isMultiRegion) + put("regionData", auth.regions.map { it.toMap() }) + } companion object { const val NAME = "FronteggRN" diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt b/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt index 9030256..c47bb99 100644 --- a/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt +++ b/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt @@ -1,17 +1,34 @@ package com.frontegg.reactnative -import com.facebook.react.ReactPackage +import com.facebook.react.TurboReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.uimanager.ViewManager +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider -class FronteggRNPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(FronteggRNModule(reactContext)) +class FronteggRNPackage : TurboReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == FronteggRNModule.NAME) { + FronteggRNModule(reactContext) + } else { + null + } } - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return emptyList() + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + FronteggRNModule.NAME to ReactModuleInfo( + FronteggRNModule.NAME, + FronteggRNModule.NAME, + false, + false, + true, + false, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + ) + ) + } } } diff --git a/android/src/main/java/com/frontegg/reactnative/Parser.kt b/android/src/main/java/com/frontegg/reactnative/Parser.kt index 1b02c9a..ceaa5fa 100644 --- a/android/src/main/java/com/frontegg/reactnative/Parser.kt +++ b/android/src/main/java/com/frontegg/reactnative/Parser.kt @@ -3,12 +3,23 @@ package com.frontegg.reactnative import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap import com.frontegg.android.models.User +import com.frontegg.android.regions.RegionConfig import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import com.google.gson.Gson +fun RegionConfig.toMap(): Map = mapOf( + "key" to key, + "baseUrl" to baseUrl, + "clientId" to clientId, + "applicationId" to applicationId, +) + +fun RegionConfig.toWritableMap(): WritableMap = Arguments.makeNativeMap(toMap()) + fun User.toReadableMap(): ReadableMap? { val jsonStr = Gson().toJson(this, User::class.java) val jsonObject = JSONObject(jsonStr) diff --git a/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt b/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt new file mode 100644 index 0000000..2485961 --- /dev/null +++ b/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt @@ -0,0 +1,44 @@ +package com.frontegg.reactnative + +import android.content.Context +import android.util.Log +import com.frontegg.android.FronteggApp +import com.frontegg.android.regions.RegionConfig +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONArray +import org.json.JSONException + +private val regionsInitialized = AtomicBoolean(false) + +internal fun initRegionsFromBuildConfig(context: Context, constants: FronteggConstants) { + val regionsJson = constants.regionsJson?.takeIf { it.isNotBlank() } ?: return + if (!regionsInitialized.compareAndSet(false, true)) { + return + } + val regions = try { + parseRegions(regionsJson) + } catch (e: JSONException) { + Log.e(TAG, "FRONTEGG_REGIONS must be a JSON array of {key, baseUrl, clientId, applicationId?}", e) + return + } + FronteggApp.initWithRegions( + regions = regions, + context = context, + useAssetsLinks = constants.useAssetsLinks, + useChromeCustomTabs = constants.useChromeCustomTabs, + ) +} + +internal fun parseRegions(regionsJson: String): List { + val regionsArray = JSONArray(regionsJson) + return (0 until regionsArray.length()).map { index -> + val region = regionsArray.getJSONObject(index) + val applicationId = if (region.isNull("applicationId")) null else region.getString("applicationId") + RegionConfig( + region.getString("key"), + region.getString("baseUrl"), + region.getString("clientId"), + applicationId, + ) + } +} diff --git a/android/src/main/java/com/frontegg/reactnative/Utils.kt b/android/src/main/java/com/frontegg/reactnative/Utils.kt index 9f1f029..0ee7d57 100644 --- a/android/src/main/java/com/frontegg/reactnative/Utils.kt +++ b/android/src/main/java/com/frontegg/reactnative/Utils.kt @@ -33,6 +33,8 @@ val Context.fronteggConstants: FronteggConstants val useChromeCustomTabs = safeGetValueFromBuildConfig( buildConfigClass, "FRONTEGG_USE_CHROME_CUSTOM_TABS", true ) + val regionsJson = + safeGetNullableValueFromBuildConfig(buildConfigClass, "FRONTEGG_REGIONS", "") return FronteggConstants( baseUrl = baseUrl, @@ -41,6 +43,7 @@ val Context.fronteggConstants: FronteggConstants useAssetsLinks = useAssetsLinks, useChromeCustomTabs = useChromeCustomTabs, bundleId = this.packageName, + regionsJson = regionsJson, ) } catch (e: ClassNotFoundException) { throw e diff --git a/android/src/newarch/FronteggRNSpec.kt b/android/src/newarch/FronteggRNSpec.kt new file mode 100644 index 0000000..be0fb6d --- /dev/null +++ b/android/src/newarch/FronteggRNSpec.kt @@ -0,0 +1,6 @@ +package com.frontegg.reactnative + +import com.facebook.react.bridge.ReactApplicationContext + +abstract class FronteggRNSpec internal constructor(context: ReactApplicationContext) : + NativeFronteggRNSpec(context) diff --git a/android/src/oldarch/FronteggRNSpec.kt b/android/src/oldarch/FronteggRNSpec.kt new file mode 100644 index 0000000..fb33739 --- /dev/null +++ b/android/src/oldarch/FronteggRNSpec.kt @@ -0,0 +1,42 @@ +package com.frontegg.reactnative + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReadableMap + +abstract class FronteggRNSpec internal constructor(context: ReactApplicationContext) : + ReactContextBaseJavaModule(context) { + + protected abstract fun getTypedExportedConstants(): Map + + override fun getConstants(): Map = getTypedExportedConstants() + + abstract fun subscribe() + abstract fun addListener(eventName: String?) + abstract fun removeListeners(count: Double) + abstract fun login(loginHint: String?, promise: Promise) + abstract fun loginWithOptions(loginHint: String?, customization: ReadableMap?, promise: Promise) + abstract fun isLoginBoxCustomizationSupported(promise: Promise) + abstract fun logout(promise: Promise) + abstract fun switchTenant(tenantId: String, promise: Promise) + abstract fun directLoginAction( + type: String, + data: String, + ephemeralSession: Boolean, + additionalQueryParams: ReadableMap?, + promise: Promise + ) + abstract fun refreshToken(promise: Promise) + abstract fun getOrRefreshAccessToken(promise: Promise) + abstract fun loginWithPasskeys(promise: Promise) + abstract fun registerPasskeys(promise: Promise) + abstract fun requestAuthorize(refreshToken: String, deviceTokenCookie: String?, promise: Promise) + abstract fun isSteppedUp(maxAgeSeconds: Double, promise: Promise) + abstract fun stepUp(maxAgeSeconds: Double, promise: Promise) + abstract fun openAdminPortal(promise: Promise) + abstract fun loadEntitlements(forceRefresh: Boolean, promise: Promise) + abstract fun getFeatureEntitlement(key: String, promise: Promise) + abstract fun getPermissionEntitlement(key: String, promise: Promise) + abstract fun initWithRegion(regionKey: String, promise: Promise) +} diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 0000000..3c7d11b --- /dev/null +++ b/app.plugin.js @@ -0,0 +1 @@ +module.exports = require('./plugin/build'); diff --git a/docs/README.md b/docs/README.md index 1664368..13a8f19 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,6 +71,19 @@ client ID from `build.gradle`. Both are covered step by step in the [Setup guide](https://react-native-guide.frontegg.com/#/setup) — this is the one part that is not JavaScript, and it differs per platform. +On Expo, add the config plugin to `app.json` instead and run `npx expo prebuild` — it generates both +native configurations for you ([Expo setup](https://react-native-guide.frontegg.com/#/setup?id=expo)): + +```json +{ + "expo": { + "plugins": [ + ["@frontegg/react-native", { "baseUrl": "https://{{FRONTEGG_BASE_URL}}", "clientId": "{{FRONTEGG_CLIENT_ID}}" }] + ] + } +} +``` + **3 · Wrap your app.** ```tsx diff --git a/docs/advanced.md b/docs/advanced.md index e9bbe9c..049723d 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -45,6 +45,78 @@ buildConfigField "String", "FRONTEGG_APPLICATION_ID", "\"$fronteggApplicationId\ ``` +### Multi-region support + +Configure every region natively, then let the user pick one from JavaScript. The selection is persisted, so the SDK starts in the same region on the next launch. + +#### iOS setup + +Replace `baseUrl`/`clientId` in `Frontegg.plist` with a `regions` array: + +```xml +regions + + + key + eu + baseUrl + https://{{FRONTEGG_EU_DOMAIN}} + clientId + {{FRONTEGG_EU_CLIENT_ID}} + + + key + us + baseUrl + https://{{FRONTEGG_US_DOMAIN}} + clientId + {{FRONTEGG_US_CLIENT_ID}} + + +``` + +Each entry also accepts an optional `applicationId`. Add an associated domain for every region's domain. + +#### Android setup + +Add a `FRONTEGG_REGIONS` BuildConfig field to `android/app/build.gradle`, holding a JSON array of `{ key, baseUrl, clientId, applicationId? }`. The SDK then initializes with these regions instead of `FRONTEGG_DOMAIN`/`FRONTEGG_CLIENT_ID`: + +```groovy +def fronteggRegions = '[{"key":"eu","baseUrl":"{{FRONTEGG_EU_DOMAIN}}","clientId":"{{FRONTEGG_EU_CLIENT_ID}}"},{"key":"us","baseUrl":"{{FRONTEGG_US_DOMAIN}}","clientId":"{{FRONTEGG_US_CLIENT_ID}}"}]' + +android { + defaultConfig { + buildConfigField "String", "FRONTEGG_REGIONS", "\"${fronteggRegions.replace('"', '\\"')}\"" + } +} +``` + +Alternatively, call `FronteggApp.initWithRegions(...)` yourself in `MainApplication.onCreate()` and omit the field; do not do both. Add intent filters (and asset links, when used) for every region's domain, as described in the [Android SDK multi-region guide](https://github.com/frontegg/frontegg-android-kotlin#multi-region-support). + +#### Select a region + +```tsx +import { Button } from 'react-native'; +import { getRegions, initWithRegion, useAuth } from '@frontegg/react-native'; + +export function RegionPicker() { + const { selectedRegion } = useAuth(); + + if (selectedRegion) { + return null; + } + return getRegions().map((region) => ( +