Skip to content
Draft
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
@@ -0,0 +1,173 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.firebase.ui.auth.ui.screens.email

import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy
import com.firebase.ui.auth.configuration.password_policy.PasswordPolicyRepository
import com.firebase.ui.auth.configuration.string_provider.AuthUIStringProvider
import com.google.firebase.auth.FirebaseAuth

/** Supplies the project password policy to [SignUpUI]. Must return `null` rather than throw. */
internal fun interface PasswordPolicySource {
suspend fun getPolicy(context: Context): PasswordPolicy?
}

/**
* Activation switch for the sign-up password requirements checklist. `null`, the default, keeps
* it off: nothing is fetched and [SignUpUI] renders as before.
*/
internal val LocalPasswordPolicySource = staticCompositionLocalOf<PasswordPolicySource?> { null }

/** A [PasswordPolicySource] for [auth]'s app and tenant, cached by this repository. */
internal fun PasswordPolicyRepository.sourceFor(auth: FirebaseAuth) =
PasswordPolicySource { context -> getPolicy(context, auth) }

/** One checklist line. [symbols] is set only on the special character line. */
internal data class PasswordRequirement(
val label: String,
val isMet: Boolean,
val symbols: String? = null,
)

/**
* The requirements [policy] configures, in the JS SDK's order, evaluated against [password]. Max
* length is not shown: its only string reads as an error even when met.
*/
internal fun passwordRequirements(
policy: PasswordPolicy,
password: String,
stringProvider: AuthUIStringProvider,
): List<PasswordRequirement> {
val status = policy.validate(password)
return buildList {
add(
PasswordRequirement(
stringProvider.passwordTooShort(policy.minPasswordLength),
status.meetsMinPasswordLength,
)
)
status.containsLowercaseLetter?.let {
add(PasswordRequirement(stringProvider.passwordMissingLowercase, it))
}
status.containsUppercaseLetter?.let {
add(PasswordRequirement(stringProvider.passwordMissingUppercase, it))
}
status.containsNumericCharacter?.let {
add(PasswordRequirement(stringProvider.passwordMissingDigit, it))
}
status.containsNonAlphanumericCharacter?.let {
val symbols = policy.allowedNonAlphanumericCharacters
.flatMap { it.codePointSymbols() }
.distinct()
.joinToString(" ")
add(
PasswordRequirement(
stringProvider.passwordMissingSpecialCharacter,
it,
symbols.ifEmpty { null },
)
)
}
}
}

/** Each code point of this string as its own string, so non-BMP symbols stay intact. */
private fun String.codePointSymbols(): List<String> {
val symbols = mutableListOf<String>()
var i = 0
while (i < length) {
val end = offsetByCodePoints(i, 1)
symbols += substring(i, end)
i = end
}
return symbols
}

/**
* Live checklist of [requirements], from [passwordRequirements]. Hints only: it never affects
* whether sign-up can be submitted. Each line exposes met/unmet as a toggleable state, so it is
* not conveyed by colour.
*/
@Composable
internal fun PasswordRequirementsChecklist(
requirements: List<PasswordRequirement>,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
requirements.forEach { requirement ->
val tint = if (requirement.isMet) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
toggleableState = ToggleableState(requirement.isMet)
},
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (requirement.isMet) {
Icons.Filled.CheckCircle
} else {
Icons.Outlined.RadioButtonUnchecked
},
contentDescription = null,
tint = tint,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = requirement.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
requirement.symbols?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,23 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.firebase.ui.auth.configuration.AuthUIConfiguration
import com.firebase.ui.auth.configuration.PasswordRule
import com.firebase.ui.auth.configuration.authUIConfiguration
import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
import com.firebase.ui.auth.configuration.password_policy.PasswordPolicy
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.configuration.validators.EmailValidator
Expand Down Expand Up @@ -76,12 +83,52 @@ fun SignUpUI(
val stringProvider = LocalAuthUIStringProvider.current
val displayNameValidator = remember { GeneralFieldValidator(stringProvider) }
val emailValidator = remember { EmailValidator(stringProvider) }
val passwordValidator = remember {
// Set on the first edit, so a re-key can tell a cleared field from an untouched one.
var passwordEdited by rememberSaveable { mutableStateOf(false) }
// Keyed on the inputs it is built from, so a swapped provider keeps the wording in sync.
// They are stable while typing, so the validator is not rebuilt per keystroke.
val passwordValidator = remember(
stringProvider,
provider.minimumPasswordLength,
provider.passwordValidationRules,
) {
PasswordValidator(
stringProvider = stringProvider,
rules = provider.passwordValidationRules
)
// Same order as the submit path: minimumPasswordLength first, then the custom rules.
// The validator reports only the first failing rule, so there is one message at most.
rules = listOf(PasswordRule.MinimumLength(provider.minimumPasswordLength)) +
provider.passwordValidationRules
).also {
// A new instance starts without an error, so re-validate typed input to keep it shown.
// Only an untouched field stays unseeded, so a re-key adds no error to it.
if (password.isNotEmpty() || passwordEdited) it.validate(password)
}
}
val passwordPolicySource = LocalPasswordPolicySource.current
// Hints only (never part of isFormValid). No source, no policy or OFF: nothing is shown.
val passwordPolicy by produceState<PasswordPolicy?>(null, passwordPolicySource, context) {
value = passwordPolicySource?.getPolicy(context)
?.takeIf { it.enforcementState == PasswordPolicy.EnforcementState.ENFORCE }
}
val checklistRequirements = remember(passwordPolicy, password, stringProvider) {
passwordPolicy?.let { passwordRequirements(it, password, stringProvider) }
}
// Only unmet lines: local and policy symbol sets differ, so a met line can share a failing
// local rule's label, and blanking it would leave no reason shown. For the same reason the
// special character message is never blanked while a local special character rule exists:
// the policy may accept a symbol the local rule rejects.
val hasLocalSpecialRule =
PasswordRule.RequireSpecialCharacter in provider.passwordValidationRules
val unmetChecklistLabels =
remember(checklistRequirements, hasLocalSpecialRule, stringProvider) {
checklistRequirements.orEmpty()
.filterNot { it.isMet }
.map { it.label }
.filterNot {
hasLocalSpecialRule && it == stringProvider.passwordMissingSpecialCharacter
}
.toSet()
}
val confirmPasswordValidator = remember(password) {
GeneralFieldValidator(
stringProvider = stringProvider,
Expand All @@ -92,7 +139,13 @@ fun SignUpUI(
)
}

val isFormValid = remember(displayName, email, password, confirmPassword) {
val isFormValid = remember(
displayName,
email,
password,
confirmPassword,
passwordValidator,
) {
derivedStateOf {
listOf(
!provider.isDisplayNameRequired || displayNameValidator.validate(displayName),
Expand Down Expand Up @@ -166,18 +219,26 @@ fun SignUpUI(
modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.PASSWORD_FIELD),
value = password,
validator = passwordValidator,
// A message an unmet checklist line already shows is not repeated. isError still
// marks the field, so the error remains in its semantics.
errorMessage = "".takeIf { passwordValidator.errorMessage in unmetChecklistLabels },
enabled = !isLoading,
isSecureTextField = true,
label = {
Text(stringProvider.passwordHint)
},
onValueChange = { text ->
passwordEdited = true
onPasswordChange(text)
},
visibilityToggleModifier = Modifier.testTag(
FirebaseAuthTestTags.SignUp.PASSWORD_VISIBILITY_TOGGLE
)
)
checklistRequirements?.let { requirements ->
Spacer(modifier = Modifier.height(8.dp))
PasswordRequirementsChecklist(requirements = requirements)
}
Spacer(modifier = Modifier.height(16.dp))
AuthTextField(
modifier = Modifier.testTag(FirebaseAuthTestTags.SignUp.CONFIRM_PASSWORD_FIELD),
Expand Down
Loading
Loading