Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
android:label="Custom Slots & Theming Demo"
android:exported="false"
android:theme="@style/Theme.FirebaseUIAndroid" />

<activity
android:name=".CredentialLinkingDemoActivity"
android:label="Credential Linking Demo"
android:exported="false"
android:theme="@style/Theme.FirebaseUIAndroid" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package com.firebaseui.android.demo

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.ui.screens.AuthRoute
import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen

class CredentialLinkingDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

val authUI = FirebaseAuthUI.getInstance()

val configuration = authUIConfiguration {
context = applicationContext
isCredentialLinkingEnabled = true
providers {
provider(
AuthProvider.Email(
isNewAccountsAllowed = true,
emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList(),
)
)
provider(
AuthProvider.Google(
scopes = listOf("email"),
serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
)
Comment thread
demolaf marked this conversation as resolved.
)
provider(
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
allowedCountries = emptyList(),
timeout = 120L,
)
)
}
}

setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
FirebaseAuthScreen(
configuration = configuration,
authUI = authUI,
onSignInSuccess = {},
onSignInFailure = { _: AuthException -> },
onSignInCancelled = {},
authenticatedContent = { state, uiContext ->
CredentialLinkingAuthenticatedContent(state, uiContext)
}
)
}
}
}
}
}

@Composable
private fun CredentialLinkingAuthenticatedContent(
state: AuthState,
uiContext: AuthSuccessUiContext,
) {
when (state) {
is AuthState.Success -> {
val user = state.user
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Signed in",
style = MaterialTheme.typography.headlineSmall,
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("UID: ${user.uid}", style = MaterialTheme.typography.bodySmall)
Text("Email: ${user.email ?: "—"}")
Text("Phone: ${user.phoneNumber ?: "—"}")
Text(
"Providers: ${user.providerData.map { it.providerId }}",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { uiContext.onNavigate(AuthRoute.MethodPicker) }
) {
Text("Add sign-in method")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = uiContext.onSignOut
) {
Text("Sign out")
}
}
}

is AuthState.RequiresEmailVerification -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Verify your email",
style = MaterialTheme.typography.headlineSmall,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "A verification link was sent to ${state.email}. Once verified, tap the button below.",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = uiContext.onReloadUser
) {
Text("I've verified my email")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = uiContext.onSignOut
) {
Text("Sign out")
}
}
}

else -> {}
}
}
30 changes: 30 additions & 0 deletions app/src/main/java/com/firebaseui/android/demo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ class MainActivity : ComponentActivity() {
onCustomSlotsClick = {
startActivity(Intent(this, CustomSlotsThemingDemoActivity::class.java))
},
onCredentialLinkingClick = {
startActivity(Intent(this, CredentialLinkingDemoActivity::class.java))
},
isEmulatorMode = USE_AUTH_EMULATOR
)
}
Expand All @@ -107,6 +110,7 @@ fun ChooserScreen(
onHighLevelApiClick: () -> Unit,
onLowLevelApiClick: () -> Unit,
onCustomSlotsClick: () -> Unit,
onCredentialLinkingClick: () -> Unit = {},
isEmulatorMode: Boolean = false
) {
val scrollState = rememberScrollState()
Expand Down Expand Up @@ -272,6 +276,32 @@ fun ChooserScreen(
}
}

// Credential Linking Card
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onCredentialLinkingClick
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "🔗 Credential Linking",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "isCredentialLinkingEnabled",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Sign in with one provider, then add another to the same account without losing your UID.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

Spacer(modifier = Modifier.height(16.dp))

// Info card
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class AuthUIConfigurationBuilder {
var isCredentialManagerEnabled: Boolean = true
var isMfaEnabled: Boolean = true
var isAnonymousUpgradeEnabled: Boolean = false
var isCredentialLinkingEnabled: Boolean = false
var tosUrl: String? = null
var privacyPolicyUrl: String? = null
var logo: AuthUIAsset? = null
Expand Down Expand Up @@ -107,6 +108,7 @@ class AuthUIConfigurationBuilder {
isCredentialManagerEnabled = isCredentialManagerEnabled,
isMfaEnabled = isMfaEnabled,
isAnonymousUpgradeEnabled = isAnonymousUpgradeEnabled,
isCredentialLinkingEnabled = isCredentialLinkingEnabled,
tosUrl = tosUrl,
privacyPolicyUrl = privacyPolicyUrl,
logo = logo,
Expand Down Expand Up @@ -164,6 +166,13 @@ class AuthUIConfiguration(
*/
val isAnonymousUpgradeEnabled: Boolean = false,

/**
* Allows linking a new credential to an already authenticated (non-anonymous) user.
* When enabled, signing in via FirebaseUI while a user is already signed in will link
* the new credential to the existing account instead of creating a new one.
*/
val isCredentialLinkingEnabled: Boolean = false,

/**
* The URL for the terms of service.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,13 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
&& currentUser.isAnonymous
}

internal fun canLinkCredential(config: AuthUIConfiguration, auth: FirebaseAuth): Boolean {
val currentUser = auth.currentUser
return config.isCredentialLinkingEnabled
&& currentUser != null
&& !currentUser.isAnonymous
}

/**
* Merges profile information (display name and photo URL) with the current user's profile.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
import com.firebase.ui.auth.FirebaseAuthUI
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canLinkCredential
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.canUpgradeAnonymous
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider.Companion.mergeProfile
import com.firebase.ui.auth.credentialmanager.PasswordCredentialCancelledException
Expand Down Expand Up @@ -126,12 +127,14 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
credentialProvider: AuthProvider.Email.CredentialProvider = AuthProvider.Email.DefaultCredentialProvider(),
): AuthResult? {
val canUpgrade = canUpgradeAnonymous(config, auth)
val canLink = canLinkCredential(config, auth)
val shouldLinkCredential = canUpgrade || canLink
val pendingCredential =
if (canUpgrade) credentialProvider.getCredential(email, password) else null
if (shouldLinkCredential) credentialProvider.getCredential(email, password) else null

try {
// Check if new accounts are allowed (only for non-upgrade flows)
if (!canUpgrade && !provider.isNewAccountsAllowed) {
// Check if new accounts are allowed (only for non-upgrade/non-linking flows)
if (!shouldLinkCredential && !provider.isNewAccountsAllowed) {
throw AuthException.UserNotFoundException(
message = context.getString(R.string.fui_error_email_does_not_exist)
)
Expand All @@ -156,7 +159,7 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
}

updateAuthState(AuthState.Loading("Creating user..."))
val result = if (canUpgrade) {
val result = if (shouldLinkCredential) {
auth.currentUser?.linkWithCredential(requireNotNull(pendingCredential))?.await()
} else {
auth.createUserWithEmailAndPassword(email, password).await()
Expand Down Expand Up @@ -205,10 +208,10 @@ internal suspend fun FirebaseAuthUI.createOrLinkUserWithEmailAndPassword(
message = "An account already exists with this email. " +
"Please sign in with your existing account.",
email = e.email ?: email,
credential = if (canUpgrade) {
e.updatedCredential ?: pendingCredential
} else {
null
credential = when {
canUpgrade -> e.updatedCredential ?: pendingCredential
canLink -> pendingCredential
else -> null
},
cause = e
)
Expand Down Expand Up @@ -548,7 +551,7 @@ internal suspend fun FirebaseAuthUI.signInAndLinkWithCredential(
): AuthResult? {
try {
updateAuthState(AuthState.Loading("Signing in user..."))
return if (canUpgradeAnonymous(config, auth)) {
return if (canUpgradeAnonymous(config, auth) || canLinkCredential(config, auth)) {
auth.currentUser?.linkWithCredential(credential)?.await()
} else {
auth.signInWithCredential(credential).await()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import com.google.firebase.auth.AuthCredential
import com.google.firebase.auth.AuthResult
import com.google.firebase.auth.MultiFactorResolver
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await

/**
* High-level authentication screen that wires together provider selection, individual provider
Expand Down Expand Up @@ -383,27 +384,22 @@ fun FirebaseAuthScreen(
coroutineScope.launch {
try {
// Reload user to get fresh data from server
authUI.getCurrentUser()?.reload()
authUI.getCurrentUser()?.getIdToken(true)

// Check the user's email verification status after reload
val user = authUI.getCurrentUser()
if (user != null) {
// If email is now verified, transition to Success state
if (user.isEmailVerified) {
authUI.getCurrentUser()?.let {
it.reload().await()
it.getIdToken(true).await()
if (it.isEmailVerified) {
authUI.updateAuthState(
AuthState.Success(
result = null,
user = user,
user = it,
isNewUser = false
)
)
} else {
// Email still not verified, keep showing verification screen
authUI.updateAuthState(
AuthState.RequiresEmailVerification(
user = user,
email = user.email ?: ""
user = it,
email = it.email ?: ""
)
)
}
Expand Down
Loading
Loading