Skip to content

Commit 23c1234

Browse files
committed
feat: Email sign in link
1 parent 50dddb3 commit 23c1234

File tree

14 files changed

+340
-237
lines changed

14 files changed

+340
-237
lines changed

auth/src/main/AndroidManifest.xml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@
8383

8484
<activity
8585
android:name=".ui.email.EmailLinkCatcherActivity"
86-
android:exported="false"
8786
android:label=""
87+
android:exported="false"
8888
android:theme="@style/FirebaseUI.Transparent"
8989
android:windowSoftInputMode="adjustResize" />
9090

@@ -119,6 +119,16 @@
119119
</intent-filter>
120120
</activity>
121121

122+
<!-- Email Link Sign-In Handler Activity for Compose -->
123+
<!-- IMPORTANT: This activity is NOT exported by default -->
124+
<!-- Users must declare this activity with an intent filter in their app's AndroidManifest.xml -->
125+
<!-- See documentation for setup instructions -->
126+
<activity
127+
android:name=".compose.ui.screens.EmailSignInLinkHandlerActivity"
128+
android:label=""
129+
android:exported="false"
130+
android:theme="@style/FirebaseUI.Transparent" />
131+
122132
<provider
123133
android:name=".data.client.AuthUiInitProvider"
124134
android:authorities="${applicationId}.authuiinitprovider"

auth/src/main/java/com/firebase/ui/auth/compose/AuthState.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ abstract class AuthState private constructor() {
243243
override fun toString(): String = "AuthState.PasswordResetLinkSent"
244244
}
245245

246+
/**
247+
* Email sign in link has been sent to the user's email.
248+
*/
249+
class EmailSignInLinkSent : AuthState() {
250+
override fun equals(other: Any?): Boolean = other is EmailSignInLinkSent
251+
override fun hashCode(): Int = javaClass.hashCode()
252+
override fun toString(): String = "AuthState.EmailSignInLinkSent"
253+
}
254+
246255
companion object {
247256
/**
248257
* Creates an Idle state instance.

auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import kotlinx.coroutines.flow.Flow
2828
import kotlinx.coroutines.flow.MutableStateFlow
2929
import kotlinx.coroutines.flow.callbackFlow
3030
import kotlinx.coroutines.flow.combine
31+
import kotlinx.coroutines.flow.distinctUntilChanged
32+
import kotlinx.coroutines.flow.drop
33+
import kotlinx.coroutines.flow.merge
34+
import kotlinx.coroutines.flow.onStart
3135
import kotlinx.coroutines.tasks.await
3236
import java.util.concurrent.ConcurrentHashMap
3337

auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,7 @@ class AuthUIConfigurationBuilder {
8585
// Provider specific validations
8686
providers.forEach { provider ->
8787
when (provider) {
88-
is AuthProvider.Email -> {
89-
provider.validate(
90-
isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled
91-
)
92-
}
93-
88+
is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled)
9489
is AuthProvider.Phone -> provider.validate()
9590
is AuthProvider.Google -> provider.validate(context)
9691
is AuthProvider.Facebook -> provider.validate(context)

auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt

Lines changed: 32 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
675675
// Save Email to dataStore for use in signInWithEmailLink
676676
EmailLinkPersistenceManager.saveEmail(context, email, sessionId, anonymousUserId)
677677

678-
updateAuthState(AuthState.Idle)
678+
updateAuthState(AuthState.EmailSignInLinkSent())
679679
} catch (e: CancellationException) {
680680
val cancelledException = AuthException.AuthCancelledException(
681681
message = "Send sign in link to email was cancelled",
@@ -727,7 +727,8 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
727727
*
728728
* @see sendSignInLinkToEmail for sending the initial email link
729729
*/
730-
internal suspend fun FirebaseAuthUI.signInWithEmailLink(
730+
// TODO(demolaf: make this internal when done testing email link sign in with composeapp
731+
suspend fun FirebaseAuthUI.signInWithEmailLink(
731732
context: Context,
732733
config: AuthUIConfiguration,
733734
provider: AuthProvider.Email,
@@ -792,7 +793,10 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
792793
// Validate anonymous user ID matches (same-device flow)
793794
if (!anonymousUserIdFromLink.isNullOrEmpty()) {
794795
val currentUser = auth.currentUser
795-
if (currentUser == null || !currentUser.isAnonymous || currentUser.uid != anonymousUserIdFromLink) {
796+
if (currentUser == null
797+
|| !currentUser.isAnonymous
798+
|| currentUser.uid != anonymousUserIdFromLink
799+
) {
796800
throw AuthException.EmailLinkDifferentAnonymousUserException()
797801
}
798802
}
@@ -804,7 +808,6 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
804808
val result = if (storedCredentialForLink == null) {
805809
// Normal Flow: Just sign in with email link
806810
signInAndLinkWithCredential(config, emailLinkCredential)
807-
?: throw AuthException.UnknownException("Sign in failed")
808811
} else {
809812
// Linking Flow: Sign in with email link, then link the social credential
810813
if (canUpgradeAnonymous(config, auth)) {
@@ -818,54 +821,39 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
818821
.getInstance(appExplicitlyForValidation)
819822

820823
// Safe link: Validate that both credentials can be linked
821-
val emailResult = authExplicitlyForValidation
824+
val result = authExplicitlyForValidation
822825
.signInWithCredential(emailLinkCredential).await()
823-
824-
val linkResult = emailResult.user
825-
?.linkWithCredential(storedCredentialForLink)?.await()
826-
827-
// If safe link succeeds, emit merge conflict for UI to handle
828-
if (linkResult?.user != null) {
829-
updateAuthState(
830-
AuthState.MergeConflict(
831-
storedCredentialForLink
826+
.user?.linkWithCredential(storedCredentialForLink)?.await()
827+
.also { result ->
828+
// If safe link succeeds, emit merge conflict for UI to handle
829+
updateAuthState(
830+
AuthState.MergeConflict(
831+
storedCredentialForLink
832+
)
832833
)
833-
)
834-
}
835-
836-
// Return the link result (will be non-null if successful)
837-
linkResult
834+
}
835+
return result
838836
} else {
839837
// Non-upgrade: Sign in with email link, then link social credential
840-
val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await()
841-
842-
// Link the social credential
843-
val linkResult = emailLinkResult.user
844-
?.linkWithCredential(storedCredentialForLink)?.await()
845-
846-
// Merge profile from the linked social credential
847-
linkResult?.user?.let { user ->
848-
mergeProfile(auth, user.displayName, user.photoUrl)
849-
}
850-
851-
// Update to success state
852-
if (linkResult?.user != null) {
853-
updateAuthState(
854-
AuthState.Success(
855-
result = linkResult,
856-
user = linkResult.user!!,
857-
isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false
858-
)
859-
)
860-
}
861-
862-
linkResult
838+
val result = auth.signInWithCredential(emailLinkCredential).await()
839+
// Link the social credential
840+
.user?.linkWithCredential(storedCredentialForLink)?.await()
841+
.also { result ->
842+
result?.user?.let { user ->
843+
// Merge profile from the linked social credential
844+
mergeProfile(
845+
auth,
846+
user.displayName,
847+
user.photoUrl
848+
)
849+
}
850+
}
851+
return result
863852
}
864853
}
865-
866854
// Clear DataStore after success
867855
EmailLinkPersistenceManager.clear(context)
868-
856+
updateAuthState(AuthState.Idle)
869857
return result
870858
} catch (e: CancellationException) {
871859
val cancelledException = AuthException.AuthCancelledException(

auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt

Lines changed: 14 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.createOrLinkUser
3333
import com.firebase.ui.auth.compose.configuration.auth_provider.sendPasswordResetEmail
3434
import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkToEmail
3535
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword
36+
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink
3637
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
3738
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
39+
import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager
3840
import com.google.firebase.auth.AuthResult
3941
import kotlinx.coroutines.launch
4042

@@ -74,6 +76,8 @@ enum class EmailAuthMode {
7476
* send a password reset email.
7577
* @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link
7678
* has been successfully sent.
79+
* @param emailSignInLinkSent (Mode: [EmailAuthMode.SignIn]) true after the email sign in link has
80+
* been successfully sent.
7781
* @param onGoToSignUp A callback to switch the UI to the SignUp mode.
7882
* @param onGoToSignIn A callback to switch the UI to the SignIn mode.
7983
* @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode.
@@ -94,6 +98,7 @@ class EmailAuthContentState(
9498
val onSignUpClick: () -> Unit,
9599
val onSendResetLinkClick: () -> Unit,
96100
val resetLinkSent: Boolean = false,
101+
val emailSignInLinkSent: Boolean = false,
97102
val onGoToSignUp: () -> Unit,
98103
val onGoToSignIn: () -> Unit,
99104
val onGoToResetPassword: () -> Unit,
@@ -104,8 +109,7 @@ class EmailAuthContentState(
104109
* including sign-in, sign-up, and password reset. It exposes the state for the current mode to
105110
* a custom UI via a trailing lambda (slot), allowing for complete visual customization.
106111
*
107-
* @param provider The configuration object contains rules for email auth, such as whether a
108-
* display name is required.
112+
* @param configuration
109113
* @param onSuccess
110114
* @param onError
111115
* @param onCancel
@@ -116,12 +120,12 @@ fun EmailAuthScreen(
116120
context: Context,
117121
configuration: AuthUIConfiguration,
118122
authUI: FirebaseAuthUI,
119-
provider: AuthProvider.Email,
120123
onSuccess: (AuthResult) -> Unit,
121124
onError: (AuthException) -> Unit,
122125
onCancel: () -> Unit,
123126
content: @Composable ((EmailAuthContentState) -> Unit)? = null,
124127
) {
128+
val provider = configuration.providers.filterIsInstance<AuthProvider.Email>().first()
125129
val stringProvider = DefaultAuthUIStringProvider(context)
126130
val coroutineScope = rememberCoroutineScope()
127131

@@ -144,6 +148,7 @@ fun EmailAuthScreen(
144148
val errorMessage =
145149
if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
146150
val resetLinkSent = authState is AuthState.PasswordResetLinkSent
151+
val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent
147152

148153
val isErrorDialogVisible =
149154
remember(authState) { mutableStateOf(authState is AuthState.Error) }
@@ -178,6 +183,7 @@ fun EmailAuthScreen(
178183
isLoading = isLoading,
179184
error = errorMessage,
180185
resetLinkSent = resetLinkSent,
186+
emailSignInLinkSent = emailSignInLinkSent,
181187
onEmailChange = { email ->
182188
emailTextValue.value = email
183189
},
@@ -260,25 +266,15 @@ fun EmailAuthScreen(
260266
if (isErrorDialogVisible.value) {
261267
ErrorRecoveryDialog(
262268
error = when ((authState as AuthState.Error).exception) {
263-
is AuthException -> {
264-
(authState as AuthState.Error).exception as AuthException
265-
}
266-
267-
else -> {
268-
AuthException
269-
.from((authState as AuthState.Error).exception)
270-
}
269+
is AuthException -> (authState as AuthState.Error).exception as AuthException
270+
else -> AuthException
271+
.from((authState as AuthState.Error).exception)
271272
},
272273
stringProvider = stringProvider,
273274
onRetry = { exception ->
274275
when (exception) {
275-
is AuthException.InvalidCredentialsException -> {
276-
state.onSignInClick()
277-
}
278-
279-
is AuthException.EmailAlreadyInUseException -> {
280-
state.onGoToSignIn()
281-
}
276+
is AuthException.InvalidCredentialsException -> state.onSignInClick()
277+
is AuthException.EmailAlreadyInUseException -> state.onGoToSignIn()
282278
}
283279
isErrorDialogVisible.value = false
284280
},
@@ -290,102 +286,3 @@ fun EmailAuthScreen(
290286

291287
content?.invoke(state)
292288
}
293-
294-
//@Preview
295-
//@Composable
296-
//internal fun PreviewEmailAuthScreen() {
297-
// val applicationContext = LocalContext.current
298-
// val provider = AuthProvider.Email(
299-
// isDisplayNameRequired = true,
300-
// isEmailLinkSignInEnabled = false,
301-
// isEmailLinkForceSameDeviceEnabled = true,
302-
// actionCodeSettings = null,
303-
// isNewAccountsAllowed = true,
304-
// minimumPasswordLength = 8,
305-
// passwordValidationRules = listOf()
306-
// )
307-
//
308-
// AuthUITheme {
309-
// EmailAuthScreen(
310-
// context = applicationContext,
311-
// configuration = authUIConfiguration {
312-
// context = applicationContext
313-
// providers { provider(provider) }
314-
// tosUrl = ""
315-
// privacyPolicyUrl = ""
316-
// },
317-
// authUI = null,
318-
// provider = provider,
319-
// onSuccess = {
320-
//
321-
// },
322-
// onError = {
323-
//
324-
// },
325-
// onCancel = {
326-
//
327-
// },
328-
// ) { state ->
329-
// when (state.mode) {
330-
// EmailAuthMode.SignIn -> {
331-
// SignInUI(
332-
// configuration = authUIConfiguration {
333-
// context = applicationContext
334-
// providers { provider(provider) }
335-
// tosUrl = ""
336-
// privacyPolicyUrl = ""
337-
// },
338-
// provider = provider,
339-
// email = state.email,
340-
// isLoading = false,
341-
// password = state.password,
342-
// onEmailChange = state.onEmailChange,
343-
// onPasswordChange = state.onPasswordChange,
344-
// onSignInClick = state.onSignInClick,
345-
// onGoToSignUp = state.onGoToSignUp,
346-
// onGoToResetPassword = state.onGoToResetPassword,
347-
// )
348-
// }
349-
//
350-
// EmailAuthMode.SignUp -> {
351-
// SignUpUI(
352-
// configuration = authUIConfiguration {
353-
// context = applicationContext
354-
// providers { provider(provider) }
355-
// tosUrl = ""
356-
// privacyPolicyUrl = ""
357-
// },
358-
// isLoading = state.isLoading,
359-
// displayName = state.displayName,
360-
// email = state.email,
361-
// password = state.password,
362-
// confirmPassword = state.confirmPassword,
363-
// onDisplayNameChange = state.onDisplayNameChange,
364-
// onEmailChange = state.onEmailChange,
365-
// onPasswordChange = state.onPasswordChange,
366-
// onConfirmPasswordChange = state.onConfirmPasswordChange,
367-
// onSignUpClick = state.onSignUpClick,
368-
// onGoToSignIn = state.onGoToSignIn,
369-
// )
370-
// }
371-
//
372-
// EmailAuthMode.ResetPassword -> {
373-
// ResetPasswordUI(
374-
// configuration = authUIConfiguration {
375-
// context = applicationContext
376-
// providers { provider(provider) }
377-
// tosUrl = ""
378-
// privacyPolicyUrl = ""
379-
// },
380-
// isLoading = state.isLoading,
381-
// email = state.email,
382-
// resetLinkSent = state.resetLinkSent,
383-
// onEmailChange = state.onEmailChange,
384-
// onSendResetLink = state.onSendResetLinkClick,
385-
// onGoToSignIn = state.onGoToSignIn
386-
// )
387-
// }
388-
// }
389-
// }
390-
// }
391-
//}

0 commit comments

Comments
 (0)