Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,6 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
* A list of custom password validation rules.
*/
val passwordValidationRules: List<PasswordRule>,

/**
* Optional custom button label to differentiate between multiple email providers.
* If null, uses the default string from stringProvider. Defaults to null.
*/
val buttonLabel: String? = null,
) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) {
companion object {
const val SESSION_ID_LENGTH = 10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ class AuthUITheme(
val topAppBarColors
get() = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ internal object ProviderStyleDefaults {
Provider.FACEBOOK -> {
provider.id to AuthUITheme.ProviderStyle(
icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp),
backgroundColor = Color(0xFF3B5998),
backgroundColor = Color(0xFF1877F2),
contentColor = Color.White
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -106,12 +107,16 @@ fun AuthProviderButton(
val iconTint = providerStyle.iconTint
if (iconTint != null) {
Icon(
modifier = Modifier
.size(24.dp),
painter = providerIcon.painter,
contentDescription = providerLabel,
tint = iconTint
)
} else {
Image(
modifier = Modifier
.size(24.dp),
painter = providerIcon.painter,
contentDescription = providerLabel
)
Expand Down Expand Up @@ -153,10 +158,6 @@ internal fun resolveProviderLabel(
context: android.content.Context
): String = when (provider) {
is AuthProvider.GenericOAuth -> provider.buttonLabel
is AuthProvider.Email -> {
// Use custom button label if provided, otherwise use default
provider.buttonLabel ?: stringProvider.signInWithEmail
}
is AuthProvider.Apple -> {
// Use Apple-specific locale if provided, otherwise use default stringProvider
if (provider.locale != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.firebase.ui.auth.compose.AuthException
import com.firebase.ui.auth.compose.AuthState
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider

/**
Expand Down Expand Up @@ -66,12 +67,15 @@ val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?
* @since 10.0.0
*/
class TopLevelDialogController(
private val stringProvider: AuthUIStringProvider
private val stringProvider: AuthUIStringProvider,
private val authState: AuthState
) {
private var dialogState by mutableStateOf<DialogState?>(null)
private val shownErrorStates = mutableSetOf<AuthState.Error>()

/**
* Shows an error recovery dialog at the top level using [ErrorRecoveryDialog].
* Automatically prevents duplicate dialogs for the same AuthState.Error instance.
*
* @param exception The auth exception to display
* @param onRetry Callback when user clicks retry button
Expand All @@ -84,6 +88,17 @@ class TopLevelDialogController(
onRecover: (AuthException) -> Unit = {},
onDismiss: () -> Unit = {}
) {
// Get current error state
val currentErrorState = authState as? AuthState.Error

// If this exact error state has already been shown, skip
if (currentErrorState != null && currentErrorState in shownErrorStates) {
return
}

// Mark this error state as shown
currentErrorState?.let { shownErrorStates.add(it) }

dialogState = DialogState.ErrorDialog(
exception = exception,
onRetry = onRetry,
Expand Down Expand Up @@ -148,9 +163,10 @@ class TopLevelDialogController(
*/
@Composable
fun rememberTopLevelDialogController(
stringProvider: AuthUIStringProvider
stringProvider: AuthUIStringProvider,
authState: AuthState
): TopLevelDialogController {
return remember(stringProvider) {
TopLevelDialogController(stringProvider)
return remember(stringProvider, authState) {
TopLevelDialogController(stringProvider, authState)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ fun FirebaseAuthScreen(
val coroutineScope = rememberCoroutineScope()
val stringProvider = DefaultAuthUIStringProvider(context)
val navController = rememberNavController()
val dialogController = rememberTopLevelDialogController(stringProvider)

val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
val dialogController = rememberTopLevelDialogController(stringProvider, authState)
val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
Expand Down Expand Up @@ -446,11 +446,6 @@ fun FirebaseAuthScreen(
} catch (e: Exception) {
Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e)
}

// Navigate to Email auth screen for cross-device error handling
if (navController.currentBackStackEntry?.destination?.route != AuthRoute.Email.route) {
navController.navigate(AuthRoute.Email.route)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import kotlinx.coroutines.launch

enum class EmailAuthMode {
SignIn,
EmailLinkSignIn,
SignUp,
ResetPassword,
}
Expand Down Expand Up @@ -95,13 +96,15 @@ class EmailAuthContentState(
val displayName: String,
val onDisplayNameChange: (String) -> Unit,
val onSignInClick: () -> Unit,
val onSignInEmailLinkClick: () -> Unit,
val onSignUpClick: () -> Unit,
val onSendResetLinkClick: () -> Unit,
val resetLinkSent: Boolean = false,
val emailSignInLinkSent: Boolean = false,
val onGoToSignUp: () -> Unit,
val onGoToSignIn: () -> Unit,
val onGoToResetPassword: () -> Unit,
val onGoToEmailLinkSignIn: () -> Unit,
)

/**
Expand Down Expand Up @@ -132,7 +135,13 @@ fun EmailAuthScreen(
val dialogController = LocalTopLevelDialogController.current
val coroutineScope = rememberCoroutineScope()

val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) }
// Start in EmailLinkSignIn mode if coming from cross-device flow
val initialMode = if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) {
EmailAuthMode.EmailLinkSignIn
} else {
EmailAuthMode.SignIn
}
val mode = rememberSaveable { mutableStateOf(initialMode) }
val displayNameValue = rememberSaveable { mutableStateOf("") }
val emailTextValue = rememberSaveable { mutableStateOf("") }
val passwordTextValue = rememberSaveable { mutableStateOf("") }
Expand Down Expand Up @@ -166,32 +175,26 @@ fun EmailAuthScreen(
is AuthState.Error -> {
val exception = AuthException.from(state.exception)
onError(exception)

// Show dialog for screen-specific errors using top-level controller
// Navigation-related errors are handled by FirebaseAuthScreen
if (exception !is AuthException.AccountLinkingRequiredException &&
exception !is AuthException.EmailLinkPromptForEmailException &&
exception !is AuthException.EmailLinkCrossDeviceLinkingException
) {
dialogController?.showErrorDialog(
exception = exception,
onRetry = { ex ->
when (ex) {
is AuthException.InvalidCredentialsException -> {
// User can retry sign in with corrected credentials
}
is AuthException.EmailAlreadyInUseException -> {
// Switch to sign-in mode
mode.value = EmailAuthMode.SignIn
}
else -> Unit
dialogController?.showErrorDialog(
exception = exception,
onRetry = { ex ->
when (ex) {
is AuthException.InvalidCredentialsException -> {
// User can retry sign in with corrected credentials
}
},
onDismiss = {
// Dialog dismissed

is AuthException.EmailAlreadyInUseException -> {
// Switch to sign-in mode
mode.value = EmailAuthMode.SignIn
}

else -> Unit
}
)
}
},
onDismiss = {
// Dialog dismissed
}
)
}

is AuthState.Cancelled -> {
Expand Down Expand Up @@ -227,36 +230,37 @@ fun EmailAuthScreen(
onSignInClick = {
coroutineScope.launch {
try {
when {
emailLinkFromDifferentDevice != null -> {
authUI.signInWithEmailLink(
context = context,
config = configuration,
provider = provider,
email = emailTextValue.value,
emailLink = emailLinkFromDifferentDevice,
)
}

provider.isEmailLinkSignInEnabled -> {
authUI.sendSignInLinkToEmail(
context = context,
config = configuration,
provider = provider,
email = emailTextValue.value,
credentialForLinking = authCredentialForLinking,
)
}

else -> {
authUI.signInWithEmailAndPassword(
context = context,
config = configuration,
email = emailTextValue.value,
password = passwordTextValue.value,
credentialForLinking = authCredentialForLinking,
)
}
authUI.signInWithEmailAndPassword(
context = context,
config = configuration,
email = emailTextValue.value,
password = passwordTextValue.value,
credentialForLinking = authCredentialForLinking,
)
} catch (e: Exception) {
onError(AuthException.from(e))
}
}
},
onSignInEmailLinkClick = {
coroutineScope.launch {
try {
if (emailLinkFromDifferentDevice != null) {
authUI.signInWithEmailLink(
context = context,
config = configuration,
provider = provider,
email = emailTextValue.value,
emailLink = emailLinkFromDifferentDevice,
)
} else {
authUI.sendSignInLinkToEmail(
context = context,
config = configuration,
provider = provider,
email = emailTextValue.value,
credentialForLinking = authCredentialForLinking,
)
}
} catch (e: Exception) {
onError(AuthException.from(e))
Expand Down Expand Up @@ -302,7 +306,11 @@ fun EmailAuthScreen(
onGoToResetPassword = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.ResetPassword
}
},
onGoToEmailLinkSignIn = {
textValues.forEach { it.value = "" }
mode.value = EmailAuthMode.EmailLinkSignIn
},
)

if (content != null) {
Expand All @@ -320,7 +328,7 @@ fun EmailAuthScreen(
private fun DefaultEmailAuthContent(
configuration: AuthUIConfiguration,
state: EmailAuthContentState,
onCancel: () -> Unit
onCancel: () -> Unit,
) {
when (state.mode) {
EmailAuthMode.SignIn -> {
Expand All @@ -335,6 +343,21 @@ private fun DefaultEmailAuthContent(
onSignInClick = state.onSignInClick,
onGoToSignUp = state.onGoToSignUp,
onGoToResetPassword = state.onGoToResetPassword,
onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn,
onNavigateBack = onCancel
)
}

EmailAuthMode.EmailLinkSignIn -> {
SignInEmailLinkUI(
configuration = configuration,
email = state.email,
isLoading = state.isLoading,
emailSignInLinkSent = state.emailSignInLinkSent,
onEmailChange = state.onEmailChange,
onSignInWithEmailLink = state.onSignInEmailLinkClick,
onGoToSignIn = state.onGoToSignIn,
onGoToResetPassword = state.onGoToResetPassword,
onNavigateBack = onCancel
)
}
Expand Down
Loading