Skip to content

Commit e5d5f1d

Browse files
authored
fix: Support both Email/Password and Passwordless/Link if enabled (#2266)
* fix: email link sign in support * fix: email link sign in and tests * update facebook brand * fix ci * fix: first and last name before email * track shown error states * add back button to sign in with phone
1 parent 99a930c commit e5d5f1d

File tree

19 files changed

+525
-212
lines changed

19 files changed

+525
-212
lines changed

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,6 @@ abstract class AuthProvider(open val providerId: String, open val providerName:
154154
* A list of custom password validation rules.
155155
*/
156156
val passwordValidationRules: List<PasswordRule>,
157-
158-
/**
159-
* Optional custom button label to differentiate between multiple email providers.
160-
* If null, uses the default string from stringProvider. Defaults to null.
161-
*/
162-
val buttonLabel: String? = null,
163157
) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) {
164158
companion object {
165159
const val SESSION_ID_LENGTH = 10

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ class AuthUITheme(
144144
val topAppBarColors
145145
get() = TopAppBarDefaults.topAppBarColors(
146146
containerColor = MaterialTheme.colorScheme.primary,
147-
titleContentColor = MaterialTheme.colorScheme.onPrimary
147+
titleContentColor = MaterialTheme.colorScheme.onPrimary,
148+
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
148149
)
149150
}
150151
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ internal object ProviderStyleDefaults {
4343
Provider.FACEBOOK -> {
4444
provider.id to AuthUITheme.ProviderStyle(
4545
icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp),
46-
backgroundColor = Color(0xFF3B5998),
46+
backgroundColor = Color(0xFF1877F2),
4747
contentColor = Color.White
4848
)
4949
}

auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.PaddingValues
2121
import androidx.compose.foundation.layout.Row
2222
import androidx.compose.foundation.layout.Spacer
2323
import androidx.compose.foundation.layout.fillMaxSize
24+
import androidx.compose.foundation.layout.size
2425
import androidx.compose.foundation.layout.width
2526
import androidx.compose.foundation.shape.RoundedCornerShape
2627
import androidx.compose.material.icons.Icons
@@ -106,12 +107,16 @@ fun AuthProviderButton(
106107
val iconTint = providerStyle.iconTint
107108
if (iconTint != null) {
108109
Icon(
110+
modifier = Modifier
111+
.size(24.dp),
109112
painter = providerIcon.painter,
110113
contentDescription = providerLabel,
111114
tint = iconTint
112115
)
113116
} else {
114117
Image(
118+
modifier = Modifier
119+
.size(24.dp),
115120
painter = providerIcon.painter,
116121
contentDescription = providerLabel
117122
)
@@ -153,10 +158,6 @@ internal fun resolveProviderLabel(
153158
context: android.content.Context
154159
): String = when (provider) {
155160
is AuthProvider.GenericOAuth -> provider.buttonLabel
156-
is AuthProvider.Email -> {
157-
// Use custom button label if provided, otherwise use default
158-
provider.buttonLabel ?: stringProvider.signInWithEmail
159-
}
160161
is AuthProvider.Apple -> {
161162
// Use Apple-specific locale if provided, otherwise use default stringProvider
162163
if (provider.locale != null) {

auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf
2121
import androidx.compose.runtime.remember
2222
import androidx.compose.runtime.setValue
2323
import com.firebase.ui.auth.compose.AuthException
24+
import com.firebase.ui.auth.compose.AuthState
2425
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
2526

2627
/**
@@ -66,12 +67,15 @@ val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?
6667
* @since 10.0.0
6768
*/
6869
class TopLevelDialogController(
69-
private val stringProvider: AuthUIStringProvider
70+
private val stringProvider: AuthUIStringProvider,
71+
private val authState: AuthState
7072
) {
7173
private var dialogState by mutableStateOf<DialogState?>(null)
74+
private val shownErrorStates = mutableSetOf<AuthState.Error>()
7275

7376
/**
7477
* Shows an error recovery dialog at the top level using [ErrorRecoveryDialog].
78+
* Automatically prevents duplicate dialogs for the same AuthState.Error instance.
7579
*
7680
* @param exception The auth exception to display
7781
* @param onRetry Callback when user clicks retry button
@@ -84,6 +88,17 @@ class TopLevelDialogController(
8488
onRecover: (AuthException) -> Unit = {},
8589
onDismiss: () -> Unit = {}
8690
) {
91+
// Get current error state
92+
val currentErrorState = authState as? AuthState.Error
93+
94+
// If this exact error state has already been shown, skip
95+
if (currentErrorState != null && currentErrorState in shownErrorStates) {
96+
return
97+
}
98+
99+
// Mark this error state as shown
100+
currentErrorState?.let { shownErrorStates.add(it) }
101+
87102
dialogState = DialogState.ErrorDialog(
88103
exception = exception,
89104
onRetry = onRetry,
@@ -148,9 +163,10 @@ class TopLevelDialogController(
148163
*/
149164
@Composable
150165
fun rememberTopLevelDialogController(
151-
stringProvider: AuthUIStringProvider
166+
stringProvider: AuthUIStringProvider,
167+
authState: AuthState
152168
): TopLevelDialogController {
153-
return remember(stringProvider) {
154-
TopLevelDialogController(stringProvider)
169+
return remember(stringProvider, authState) {
170+
TopLevelDialogController(stringProvider, authState)
155171
}
156172
}

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,9 @@ fun FirebaseAuthScreen(
9797
val coroutineScope = rememberCoroutineScope()
9898
val stringProvider = DefaultAuthUIStringProvider(context)
9999
val navController = rememberNavController()
100-
val dialogController = rememberTopLevelDialogController(stringProvider)
101100

102101
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
102+
val dialogController = rememberTopLevelDialogController(stringProvider, authState)
103103
val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
104104
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
105105
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
@@ -446,11 +446,6 @@ fun FirebaseAuthScreen(
446446
} catch (e: Exception) {
447447
Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e)
448448
}
449-
450-
// Navigate to Email auth screen for cross-device error handling
451-
if (navController.currentBackStackEntry?.destination?.route != AuthRoute.Email.route) {
452-
navController.navigate(AuthRoute.Email.route)
453-
}
454449
}
455450
}
456451

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

Lines changed: 80 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
4242

4343
enum class EmailAuthMode {
4444
SignIn,
45+
EmailLinkSignIn,
4546
SignUp,
4647
ResetPassword,
4748
}
@@ -95,13 +96,15 @@ class EmailAuthContentState(
9596
val displayName: String,
9697
val onDisplayNameChange: (String) -> Unit,
9798
val onSignInClick: () -> Unit,
99+
val onSignInEmailLinkClick: () -> Unit,
98100
val onSignUpClick: () -> Unit,
99101
val onSendResetLinkClick: () -> Unit,
100102
val resetLinkSent: Boolean = false,
101103
val emailSignInLinkSent: Boolean = false,
102104
val onGoToSignUp: () -> Unit,
103105
val onGoToSignIn: () -> Unit,
104106
val onGoToResetPassword: () -> Unit,
107+
val onGoToEmailLinkSignIn: () -> Unit,
105108
)
106109

107110
/**
@@ -132,7 +135,13 @@ fun EmailAuthScreen(
132135
val dialogController = LocalTopLevelDialogController.current
133136
val coroutineScope = rememberCoroutineScope()
134137

135-
val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) }
138+
// Start in EmailLinkSignIn mode if coming from cross-device flow
139+
val initialMode = if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) {
140+
EmailAuthMode.EmailLinkSignIn
141+
} else {
142+
EmailAuthMode.SignIn
143+
}
144+
val mode = rememberSaveable { mutableStateOf(initialMode) }
136145
val displayNameValue = rememberSaveable { mutableStateOf("") }
137146
val emailTextValue = rememberSaveable { mutableStateOf("") }
138147
val passwordTextValue = rememberSaveable { mutableStateOf("") }
@@ -166,32 +175,26 @@ fun EmailAuthScreen(
166175
is AuthState.Error -> {
167176
val exception = AuthException.from(state.exception)
168177
onError(exception)
169-
170-
// Show dialog for screen-specific errors using top-level controller
171-
// Navigation-related errors are handled by FirebaseAuthScreen
172-
if (exception !is AuthException.AccountLinkingRequiredException &&
173-
exception !is AuthException.EmailLinkPromptForEmailException &&
174-
exception !is AuthException.EmailLinkCrossDeviceLinkingException
175-
) {
176-
dialogController?.showErrorDialog(
177-
exception = exception,
178-
onRetry = { ex ->
179-
when (ex) {
180-
is AuthException.InvalidCredentialsException -> {
181-
// User can retry sign in with corrected credentials
182-
}
183-
is AuthException.EmailAlreadyInUseException -> {
184-
// Switch to sign-in mode
185-
mode.value = EmailAuthMode.SignIn
186-
}
187-
else -> Unit
178+
dialogController?.showErrorDialog(
179+
exception = exception,
180+
onRetry = { ex ->
181+
when (ex) {
182+
is AuthException.InvalidCredentialsException -> {
183+
// User can retry sign in with corrected credentials
188184
}
189-
},
190-
onDismiss = {
191-
// Dialog dismissed
185+
186+
is AuthException.EmailAlreadyInUseException -> {
187+
// Switch to sign-in mode
188+
mode.value = EmailAuthMode.SignIn
189+
}
190+
191+
else -> Unit
192192
}
193-
)
194-
}
193+
},
194+
onDismiss = {
195+
// Dialog dismissed
196+
}
197+
)
195198
}
196199

197200
is AuthState.Cancelled -> {
@@ -227,36 +230,37 @@ fun EmailAuthScreen(
227230
onSignInClick = {
228231
coroutineScope.launch {
229232
try {
230-
when {
231-
emailLinkFromDifferentDevice != null -> {
232-
authUI.signInWithEmailLink(
233-
context = context,
234-
config = configuration,
235-
provider = provider,
236-
email = emailTextValue.value,
237-
emailLink = emailLinkFromDifferentDevice,
238-
)
239-
}
240-
241-
provider.isEmailLinkSignInEnabled -> {
242-
authUI.sendSignInLinkToEmail(
243-
context = context,
244-
config = configuration,
245-
provider = provider,
246-
email = emailTextValue.value,
247-
credentialForLinking = authCredentialForLinking,
248-
)
249-
}
250-
251-
else -> {
252-
authUI.signInWithEmailAndPassword(
253-
context = context,
254-
config = configuration,
255-
email = emailTextValue.value,
256-
password = passwordTextValue.value,
257-
credentialForLinking = authCredentialForLinking,
258-
)
259-
}
233+
authUI.signInWithEmailAndPassword(
234+
context = context,
235+
config = configuration,
236+
email = emailTextValue.value,
237+
password = passwordTextValue.value,
238+
credentialForLinking = authCredentialForLinking,
239+
)
240+
} catch (e: Exception) {
241+
onError(AuthException.from(e))
242+
}
243+
}
244+
},
245+
onSignInEmailLinkClick = {
246+
coroutineScope.launch {
247+
try {
248+
if (emailLinkFromDifferentDevice != null) {
249+
authUI.signInWithEmailLink(
250+
context = context,
251+
config = configuration,
252+
provider = provider,
253+
email = emailTextValue.value,
254+
emailLink = emailLinkFromDifferentDevice,
255+
)
256+
} else {
257+
authUI.sendSignInLinkToEmail(
258+
context = context,
259+
config = configuration,
260+
provider = provider,
261+
email = emailTextValue.value,
262+
credentialForLinking = authCredentialForLinking,
263+
)
260264
}
261265
} catch (e: Exception) {
262266
onError(AuthException.from(e))
@@ -302,7 +306,11 @@ fun EmailAuthScreen(
302306
onGoToResetPassword = {
303307
textValues.forEach { it.value = "" }
304308
mode.value = EmailAuthMode.ResetPassword
305-
}
309+
},
310+
onGoToEmailLinkSignIn = {
311+
textValues.forEach { it.value = "" }
312+
mode.value = EmailAuthMode.EmailLinkSignIn
313+
},
306314
)
307315

308316
if (content != null) {
@@ -320,7 +328,7 @@ fun EmailAuthScreen(
320328
private fun DefaultEmailAuthContent(
321329
configuration: AuthUIConfiguration,
322330
state: EmailAuthContentState,
323-
onCancel: () -> Unit
331+
onCancel: () -> Unit,
324332
) {
325333
when (state.mode) {
326334
EmailAuthMode.SignIn -> {
@@ -335,6 +343,21 @@ private fun DefaultEmailAuthContent(
335343
onSignInClick = state.onSignInClick,
336344
onGoToSignUp = state.onGoToSignUp,
337345
onGoToResetPassword = state.onGoToResetPassword,
346+
onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn,
347+
onNavigateBack = onCancel
348+
)
349+
}
350+
351+
EmailAuthMode.EmailLinkSignIn -> {
352+
SignInEmailLinkUI(
353+
configuration = configuration,
354+
email = state.email,
355+
isLoading = state.isLoading,
356+
emailSignInLinkSent = state.emailSignInLinkSent,
357+
onEmailChange = state.onEmailChange,
358+
onSignInWithEmailLink = state.onSignInEmailLinkClick,
359+
onGoToSignIn = state.onGoToSignIn,
360+
onGoToResetPassword = state.onGoToResetPassword,
338361
onNavigateBack = onCancel
339362
)
340363
}

0 commit comments

Comments
 (0)