diff --git a/.github/workflows/react-native-sdk-e2e.yml b/.github/workflows/react-native-sdk-e2e.yml index db55ff1..1e0adec 100644 --- a/.github/workflows/react-native-sdk-e2e.yml +++ b/.github/workflows/react-native-sdk-e2e.yml @@ -40,7 +40,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 - name: Install JS dependencies run: | @@ -170,7 +170,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 - name: Install JS dependencies run: | diff --git a/.nvmrc b/.nvmrc index 5036cb7..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.10.0 +22 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..65fa70b --- /dev/null +++ b/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt @@ -0,0 +1,102 @@ +package com.frontegg.reactnative + +import android.content.Context +import com.frontegg.android.FronteggAuth +import com.frontegg.android.utils.FronteggConstantsProvider +import com.frontegg.android.utils.JWTHelper +import com.frontegg.android.utils.NetworkGate +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 CONCURRENT_REFRESH_TIMEOUT_MS = 10_000L +private const val AUTH_STATE_POLL_INTERVAL_MS = 200L + +internal suspend fun fetchValidAccessToken(auth: FronteggAuth, context: Context): String? { + if (auth.isMultiRegion && auth.selectedRegion == null) { + return null + } + val settled = waitWhile(AUTH_STATE_SETTLE_TIMEOUT_MS) { + auth.initializing.value || auth.refreshingToken.value + } + if (!settled) { + throw IllegalStateException("Timed out waiting for authentication state to settle") + } + if (auth.refreshToken.value == null) { + return null + } + + val currentAccessToken = auth.accessToken.value + if (hasMoreThanMinimumValidity(currentAccessToken)) { + return currentAccessToken + } + + val offlineModeEnabled = isOfflineModeEnabled(context) + if (!NetworkGate.isNetworkLikelyGood(context)) { + if (offlineModeEnabled) { + return currentAccessToken + } + throw IOException("No network connection to refresh the access token") + } + + val refreshedAccessToken = refreshUntilValid(auth) + if (refreshedAccessToken != null) { + return refreshedAccessToken + } + if (auth.refreshToken.value == null) { + return null + } + if (offlineModeEnabled && !NetworkGate.isNetworkLikelyGood(context)) { + return auth.accessToken.value + } + throw IOException("Access token could not be refreshed") +} + +private suspend fun refreshUntilValid(auth: FronteggAuth): String? { + if (!auth.refreshTokenAndWait()) { + return null + } + if (hasMoreThanMinimumValidity(auth.accessToken.value)) { + return auth.accessToken.value + } + waitWhile(CONCURRENT_REFRESH_TIMEOUT_MS) { auth.refreshingToken.value } + if (hasMoreThanMinimumValidity(auth.accessToken.value)) { + return auth.accessToken.value + } + if (!auth.refreshTokenAndWait()) { + return null + } + return auth.accessToken.value?.takeIf { hasMoreThanMinimumValidity(it) } +} + +private suspend fun waitWhile(timeoutMs: Long, condition: () -> Boolean): Boolean { + val finished = withTimeoutOrNull(timeoutMs) { + while (condition()) { + delay(AUTH_STATE_POLL_INTERVAL_MS) + } + true + } + return finished == true +} + +// Same source the SDK is initialized from, both by its auto-init and by RegionsInitializer. +private fun isOfflineModeEnabled(context: Context): Boolean { + return runCatching { + FronteggConstantsProvider.fronteggConstants(context).enableOfflineMode + }.getOrDefault(false) +} + +private fun hasMoreThanMinimumValidity(accessToken: String?): Boolean { + if (accessToken == null) { + return false + } + 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..b49017c 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,35 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) : } @ReactMethod - fun loginWithPasskeys(promise: Promise) { + override fun getOrRefreshAccessToken(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + promise.resolve(fetchValidAccessToken(auth, reactContext.applicationContext)) + } 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 { + if (auth.regions.none { it.key == regionKey }) { + promise.reject("INVALID_REGION", "Region '$regionKey' is not configured") + return@launch + } + reactContext.applicationContext.fronteggApp.initWithRegion(regionKey) + notifyChanges() + promise.resolve(null) + } catch (e: Exception) { + promise.reject("INIT_WITH_REGION_ERROR", e.message, e) + } + } + } + + @ReactMethod + override fun loginWithPasskeys(promise: Promise) { withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity -> auth.loginWithPasskeys(activity) { error -> if (error != null) { @@ -250,14 +280,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 +305,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 +325,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 +338,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 +350,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 +372,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..3dc5229 --- /dev/null +++ b/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt @@ -0,0 +1,91 @@ +package com.frontegg.reactnative + +import android.content.Context +import android.util.Log +import com.frontegg.android.FronteggApp +import com.frontegg.android.regions.RegionConfig +import com.frontegg.android.utils.FronteggConstantsProvider +import com.frontegg.android.utils.TenantResolver +import com.frontegg.android.utils.TenantResolverResult +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONArray +import org.json.JSONException + +private val regionsInitialized = AtomicBoolean(false) + +object FronteggRegions { + @JvmStatic + fun initFromBuildConfig(context: Context) { + val applicationContext = context.applicationContext + initRegionsFromBuildConfig(applicationContext, applicationContext.fronteggConstants) + } +} + +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 + } + try { + initSdkWithRegions(context, constants, regions) + } catch (e: Exception) { + regionsInitialized.set(false) + Log.e(TAG, "Could not initialize the Frontegg SDK with FRONTEGG_REGIONS, will retry on the next call", e) + } +} + +private fun initSdkWithRegions( + context: Context, + constants: FronteggConstants, + regions: List, +) { + val sdkConstants = FronteggConstantsProvider.fronteggConstants(context) + FronteggApp.initWithRegions( + regions = regions, + context = context, + useAssetsLinks = constants.useAssetsLinks, + useChromeCustomTabs = constants.useChromeCustomTabs, + mainActivityClass = findClassOrNull(sdkConstants.mainActivityClass), + useDiskCacheWebview = sdkConstants.useDiskCacheWebview, + disableAutoRefresh = sdkConstants.disableAutoRefresh, + enableOfflineMode = sdkConstants.enableOfflineMode, + networkMonitoringIntervalSeconds = sdkConstants.networkMonitoringIntervalSeconds, + enableSessionPerTenant = sdkConstants.enableSessionPerTenant, + entitlementsEnabled = sdkConstants.entitlementsEnabled, + tenantResolver = sdkConstants.fronteggOrganization?.let { organization -> + TenantResolver { TenantResolverResult(organization) } + }, + ) +} + +private fun findClassOrNull(className: String?): Class<*>? { + if (className == null) { + return null + } + return try { + Class.forName(className) + } catch (e: ClassNotFoundException) { + Log.e(TAG, "FRONTEGG_MAIN_ACTIVITY_CLASS ($className) not found", e) + null + } +} + +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..56f3e84 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -45,6 +45,92 @@ 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('"', '\\"')}\"" + } +} +``` + +Then initialize the regions when the app process starts, so a login redirect that relaunches the app finds them configured before React Native loads. Call the helper right after `super.onCreate()` in `MainApplication`: + +```kotlin +import com.frontegg.reactnative.FronteggRegions + +override fun onCreate() { + super.onCreate() + FronteggRegions.initFromBuildConfig(this) + // ... +} +``` + +From a Java `MainApplication`, call `FronteggRegions.initFromBuildConfig(this);`. The Expo config plugin adds this call for you. Without it the module still initializes the regions when React Native first loads it, but a process restarted straight into the login redirect would start without them. + +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) => ( +