diff --git a/.changeset/large-aliens-arrive.md b/.changeset/large-aliens-arrive.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/large-aliens-arrive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx index bfac480af41..7c8802393e7 100644 --- a/integration/templates/expo-native/App.tsx +++ b/integration/templates/expo-native/App.tsx @@ -2,14 +2,22 @@ import { ClerkProvider, useAuth, useUser } from '@clerk/expo'; import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'; import { tokenCache } from '@clerk/expo/token-cache'; import { useState } from 'react'; -import { Button, Modal, StyleSheet, Text, View } from 'react-native'; +import { Button, LogBox, Modal, StyleSheet, Text, View } from 'react-native'; import { BiometricAvailabilityButton } from './components/BiometricAvailabilityButton'; import { GoogleSignInButton } from './components/GoogleSignInButton'; +import { readVerifyLaunch } from './verify/launch'; +import { logRequests, VerifyHost } from './verify/VerifyHost'; const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; +const verifyLaunch = readVerifyLaunch(); -if (!publishableKey) { +if (verifyLaunch) { + LogBox.ignoreAllLogs(); + if (verifyLaunch.debugLogging) { + logRequests(); + } +} else if (!publishableKey) { throw new Error('Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY'); } @@ -111,6 +119,10 @@ function NativeBuildFixture() { } export default function App() { + if (verifyLaunch) { + return ; + } + return ( + expoVersion.startsWith('57.') + ? { ...config, plugins: [...config.plugins, ['expo-build-properties', { ios: { enableSceneSupport: true } }]] } + : config; diff --git a/integration/templates/expo-native/app.json b/integration/templates/expo-native/app.json index 58e258ea523..185a1bc005f 100644 --- a/integration/templates/expo-native/app.json +++ b/integration/templates/expo-native/app.json @@ -12,6 +12,11 @@ "android": { "package": "com.clerk.exponativebuildfixture" }, - "plugins": ["expo-secure-store", "@clerk/expo", "expo-web-browser"] + "plugins": ["expo-secure-store", "@clerk/expo", "@clerk/expo-google-signin", "expo-web-browser"], + "extra": { + "EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID": "000000000000-clerkverifyplaceholderweb.apps.googleusercontent.com", + "EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID": "000000000000-clerkverifyplaceholderios.apps.googleusercontent.com", + "EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME": "com.googleusercontent.apps.000000000000-clerkverifyplaceholderios" + } } } diff --git a/integration/templates/expo-native/components/GoogleSignInButton.tsx b/integration/templates/expo-native/components/GoogleSignInButton.tsx index 61b3df286fd..2ffaa8fd2c0 100644 --- a/integration/templates/expo-native/components/GoogleSignInButton.tsx +++ b/integration/templates/expo-native/components/GoogleSignInButton.tsx @@ -12,10 +12,15 @@ export function GoogleSignInButton() { testID='google-sign-in-button' title='Sign in with Google' onPress={() => { - void startGoogleAuthenticationFlow().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - setResult(message.replace(/\s+/g, ' ')); - }); + setResult(null); + void startGoogleAuthenticationFlow().then( + ({ createdSessionId }) => + setResult(createdSessionId ? 'Google sign-in created a session' : 'Google sign-in was cancelled'), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + setResult(`Google sign-in failed: ${message.replace(/\s+/g, ' ')}`); + }, + ); }} /> {result && {result}} diff --git a/integration/templates/expo-native/metro.config.js b/integration/templates/expo-native/metro.config.js new file mode 100644 index 00000000000..58609d9362b --- /dev/null +++ b/integration/templates/expo-native/metro.config.js @@ -0,0 +1,37 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { getDefaultConfig } = require('expo/metro-config'); +const { dependencies, devDependencies } = require('./package.json'); + +const config = getDefaultConfig(__dirname); +const monorepoRoot = path.resolve(__dirname, '../../..'); +const fixtureEntry = path.join(__dirname, 'index.js'); +const fixturePackages = new Set(Object.keys({ ...dependencies, ...devDependencies })); + +function linksToWorkspace() { + try { + return ( + fs.realpathSync(path.join(__dirname, 'node_modules/@clerk/expo')) === path.join(monorepoRoot, 'packages/expo') + ); + } catch { + return false; + } +} + +function packageName(moduleName) { + const [scope, name] = moduleName.split('/'); + return scope.startsWith('@') ? `${scope}/${name}` : scope; +} + +if (linksToWorkspace()) { + config.watchFolders = [monorepoRoot]; + config.resolver.resolveRequest = (context, moduleName, platform) => { + const fromWorkspace = !context.originModulePath.startsWith(__dirname + path.sep); + if (fromWorkspace && fixturePackages.has(packageName(moduleName))) { + return context.resolveRequest({ ...context, originModulePath: fixtureEntry }, moduleName, platform); + } + return context.resolveRequest(context, moduleName, platform); + }; +} + +module.exports = config; diff --git a/integration/templates/expo-native/modules/verify-launch-config/android/build.gradle b/integration/templates/expo-native/modules/verify-launch-config/android/build.gradle new file mode 100644 index 00000000000..308e30af523 --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.verifylaunchconfig' +version = '1.0.0' + +android { + namespace "expo.modules.verifylaunchconfig" + defaultConfig { + versionCode 1 + versionName "1.0.0" + } +} diff --git a/integration/templates/expo-native/modules/verify-launch-config/android/src/main/AndroidManifest.xml b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..bdae66c8f5a --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/integration/templates/expo-native/modules/verify-launch-config/android/src/main/java/expo/modules/verifylaunchconfig/VerifyLaunchConfigModule.kt b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/java/expo/modules/verifylaunchconfig/VerifyLaunchConfigModule.kt new file mode 100644 index 00000000000..776aeadb730 --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/android/src/main/java/expo/modules/verifylaunchconfig/VerifyLaunchConfigModule.kt @@ -0,0 +1,34 @@ +package expo.modules.verifylaunchconfig + +import android.content.Context +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +private const val PREFERENCES = "verify_launch_config" +private const val STORAGE_SCOPE_KEY = "storageScope" +private val CLERK_STORAGE = listOf("clerk_preferences", "SecureStore") + +class VerifyLaunchConfigModule : Module() { + override fun definition() = ModuleDefinition { + Name("VerifyLaunchConfig") + + Function("readLaunchInputs") { + val extras = appContext.currentActivity?.intent?.extras ?: return@Function emptyMap() + extras.keySet() + .filter { it.startsWith("verify") } + .mapNotNull { key -> extras.getString(key)?.let { key to it } } + .toMap() + } + + Function("applyStorageScope") { scope: String -> + val context = appContext.reactContext ?: return@Function + val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + if (preferences.getString(STORAGE_SCOPE_KEY, null) == scope) { + return@Function + } + + CLERK_STORAGE.forEach { context.deleteSharedPreferences(it) } + preferences.edit().putString(STORAGE_SCOPE_KEY, scope).commit() + } + } +} diff --git a/integration/templates/expo-native/modules/verify-launch-config/expo-module.config.json b/integration/templates/expo-native/modules/verify-launch-config/expo-module.config.json new file mode 100644 index 00000000000..0d8cc5ab8c0 --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["VerifyLaunchConfigModule"] + }, + "android": { + "modules": ["expo.modules.verifylaunchconfig.VerifyLaunchConfigModule"] + } +} diff --git a/integration/templates/expo-native/modules/verify-launch-config/index.ts b/integration/templates/expo-native/modules/verify-launch-config/index.ts new file mode 100644 index 00000000000..ca15ce01a7b --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/index.ts @@ -0,0 +1,8 @@ +import { requireOptionalNativeModule } from 'expo'; + +type VerifyLaunchConfigModule = { + readLaunchInputs(): Record; + applyStorageScope(scope: string): void; +}; + +export const VerifyLaunchConfig = requireOptionalNativeModule('VerifyLaunchConfig'); diff --git a/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfig.podspec b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfig.podspec new file mode 100644 index 00000000000..0974bd2db66 --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfig.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'VerifyLaunchConfig' + s.version = '1.0.0' + s.summary = 'Reads verify launch inputs for the Expo native fixture' + s.author = '' + s.homepage = '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/clerk/javascript' + s.platforms = { :ios => '15.1' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.swift" +end diff --git a/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfigModule.swift b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfigModule.swift new file mode 100644 index 00000000000..88f832b9638 --- /dev/null +++ b/integration/templates/expo-native/modules/verify-launch-config/ios/VerifyLaunchConfigModule.swift @@ -0,0 +1,48 @@ +import ExpoModulesCore +import Security + +public class VerifyLaunchConfigModule: Module { + private static let storageScopeKey = "VerifyLaunchConfig.storageScope" + private static let clerkDefaultsKeys = [ + "authStartIdentifier", + "authStartPhoneNumber", + "authStartPhoneNumberFieldIsActive", + "clerk_last_used_identifier_type", + ] + + public func definition() -> ModuleDefinition { + Name("VerifyLaunchConfig") + + OnCreate { + if Self.launchInputs()["verifyLaunchId"] != nil { + Self.keepPasswordAutoFillOffNativeClerkViews() + } + } + + Function("readLaunchInputs") { () -> [String: String] in + Self.launchInputs() + } + + Function("applyStorageScope") { (scope: String) in + let defaults = UserDefaults.standard + guard defaults.string(forKey: Self.storageScopeKey) != scope else { + return + } + + SecItemDelete([kSecClass: kSecClassGenericPassword] as CFDictionary) + Self.clerkDefaultsKeys.forEach(defaults.removeObject(forKey:)) + defaults.set(scope, forKey: Self.storageScopeKey) + } + } + + private static func launchInputs() -> [String: String] { + UserDefaults.standard + .volatileDomain(forName: UserDefaults.argumentDomain) + .filter { $0.key.hasPrefix("verify") } + .compactMapValues { $0 as? String } + } + + private static func keepPasswordAutoFillOffNativeClerkViews() { + setenv("CLERK_E2E_MODE", "1", 1) + } +} diff --git a/integration/templates/expo-native/package.sdk-57.json b/integration/templates/expo-native/package.sdk-57.json index 9523431c830..070c763e076 100644 --- a/integration/templates/expo-native/package.sdk-57.json +++ b/integration/templates/expo-native/package.sdk-57.json @@ -10,7 +10,8 @@ "prebuild:ios": "expo prebuild --clean --platform ios" }, "dependencies": { - "expo": "~57.0.1", + "expo": "~57.0.23", + "expo-build-properties": "~57.0.22", "react": "19.2.8", "react-dom": "19.2.8", "react-native": "0.86.0" diff --git a/integration/templates/expo-native/screens/AuthLogo.tsx b/integration/templates/expo-native/screens/AuthLogo.tsx new file mode 100644 index 00000000000..bc5c3781f9e --- /dev/null +++ b/integration/templates/expo-native/screens/AuthLogo.tsx @@ -0,0 +1,24 @@ +import { type ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +export function AuthLogo(): ReactElement { + return ( + + E2E Custom Logo + + ); +} + +const styles = StyleSheet.create({ + logo: { + backgroundColor: '#6C47FF', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 10, + }, + logoText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/integration/templates/expo-native/screens/CustomSignIn.tsx b/integration/templates/expo-native/screens/CustomSignIn.tsx new file mode 100644 index 00000000000..06d4b1b59d2 --- /dev/null +++ b/integration/templates/expo-native/screens/CustomSignIn.tsx @@ -0,0 +1,75 @@ +import { useSignIn } from '@clerk/expo'; +import { type ReactElement, useState } from 'react'; +import { Button, Text, TextInput, View } from 'react-native'; + +import { styles } from './styles'; + +export function CustomSignIn(): ReactElement { + const { signIn, fetchStatus } = useSignIn(); + const [emailAddress, setEmailAddress] = useState(''); + const [code, setCode] = useState(''); + const [codeSent, setCodeSent] = useState(false); + const [error, setError] = useState(null); + const busy = fetchStatus === 'fetching'; + + const sendCode = async (): Promise => { + const { error } = await signIn.emailCode.sendCode({ emailAddress }); + setError(error?.message ?? null); + setCodeSent(!error); + }; + + const verifyCode = async (): Promise => { + const { error } = await signIn.emailCode.verifyCode({ code }); + if (error || signIn.status !== 'complete') { + setError(error?.message ?? `Sign-in is ${signIn.status}`); + return; + } + const finalized = await signIn.finalize(); + setError(finalized.error?.message ?? null); + }; + + return ( + + Custom sign-in + {codeSent ? ( + <> + +