Skip to content

feat(auth): add content slots to email sign-in screens - #1414

Open
demolaf wants to merge 10 commits into
auth/phone-content-slotsfrom
auth/email-content-slots
Open

demolaf wants to merge 10 commits into
auth/phone-content-slotsfrom
auth/email-content-slots

Conversation

@demolaf

@demolaf demolaf commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Adds public EmailAuthScreen, PasswordRecoveryScreen and EmailLinkScreen, so apps can draw the email sign-in screens themselves while the library keeps signing in, sending links, navigating and reporting errors. Builds on #1412.

⚠️ Breaking Changes

  • AuthView gains signIn and signUp cases, so an exhaustive switch over AuthView needs a new branch or a default:.

Changes

  • Each screen owns its input state and passes its content an EmailAuthContentState, PasswordRecoveryContentState or EmailLinkContentState: Bindings for input, closures for actions, plus isValid, isLoading and a localized errorMessage.
  • DefaultEmailAuthContent, DefaultPasswordRecoveryContent and DefaultEmailLinkContent hold the stock markup, and EmailAuthView, PasswordRecoveryView and EmailLinkView now render them through the screens.
  • signIn and signUp route MFA and account conflicts to their handlers like the phone screens, and every action ignores taps while invalid or loading.
  • Add AuthView.signIn and AuthView.signUp routes, shown by default as SignInView and SignUpView, reached with AuthMethodPickerState.goToEmailSignIn and EmailAuthContentState.goToSignUp. They close once signed in, and EmailAuthScreen(flow:) fixes a screen's flow; the stock first screen still toggles in place.
  • Password recovery failures are now reported to the sheet's error alert instead of being dropped silently.
  • Pressing Go in the password field while signing up now moves to Confirm password instead of starting a sign-in.

API Usage

AuthPickerView { authenticatedApp }
  .pickerContent {
    EmailAuthScreen { state in
      TextField("Email", text: state.email)
      SecureField("Password", text: state.password)
      if let errorMessage = state.errorMessage {
        Text(errorMessage)
      }
      Button("Sign in", action: state.signIn)
        .disabled(!state.isValid || state.isLoading)
      Button("Forgot password?", action: state.goToPasswordRecovery)
      Button("Create account", action: state.goToSignUp)
    }
  }
  .pickerDestination { screen in
    switch screen {
    case .passwordRecovery:
      PasswordRecoveryScreen { state in
        DefaultPasswordRecoveryContent(state: state)
          .background(.yellow.opacity(0.1))
      }
    case .signUp:
      EmailAuthScreen(flow: .signUp) { state in
        DefaultEmailAuthContent(state: state)
      }
    default:
      DefaultAuthPickerDestination(screen: screen)
    }
  }

Preview


Maintainer note: Fixes internal CPRN-519

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the authentication views by introducing state-driven screen components (EmailAuthScreen, EmailLinkScreen, PasswordRecoveryScreen) to decouple UI from business logic. The review identifies several high-severity issues regarding potential race conditions when capturing email state in asynchronous tasks and inconsistencies in email validation logic. Additionally, medium-severity feedback suggests improving user experience by disabling submit buttons during loading states.

Comment on lines +151 to +166
private func sendSignInLink() {
guard isValid, !isLoading else { return }
isWorking = true
errorMessage = nil
Task {
defer { isWorking = false }
do {
try await authService.sendEmailSignInLink(email: email)
didSend = true
authService.emailLink = email
} catch {
errorMessage = authService.string.localizedErrorMessage(for: error)
reportError?(error)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Capturing email dynamically inside the asynchronous Task can lead to race conditions if the user edits the text field while the request is in flight. If the email is modified before the request completes, the wrong email address will be saved to authService.emailLink, which will break the email link sign-in flow when the user opens the link. Capture the email address locally before starting the Task to ensure consistency.

  private func sendSignInLink() {
    guard isValid, !isLoading else { return }
    let emailToSend = email
    isWorking = true
    errorMessage = nil
    Task {
      defer { isWorking = false }
      do {
        try await authService.sendEmailSignInLink(email: emailToSend)
        didSend = true
        authService.emailLink = emailToSend
      } catch {
        errorMessage = authService.string.localizedErrorMessage(for: error)
        reportError?(error)
      }
    }
  }

Comment on lines +143 to +145
private var isValid: Bool {
CommonUtils.isValidEmail(email)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is an inconsistency in email validation. The screen uses CommonUtils.isValidEmail(email) to enable/disable the submit button, while the text field in DefaultEmailLinkContent uses FormValidators.email for inline validation. This can lead to cases where the button is enabled but the field shows an error, or the button is disabled with no error shown. Use FormValidators.email.isValid(input: email) to ensure consistent validation across the UI. Note that you will also need to add import FirebaseAuthUIComponents at the top of this file.

Suggested change
private var isValid: Bool {
CommonUtils.isValidEmail(email)
}
private var isValid: Bool {
FormValidators.email.isValid(input: email)
}

Comment on lines +123 to +125
private var isValid: Bool {
CommonUtils.isValidEmail(email)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is an inconsistency in email validation. The screen uses CommonUtils.isValidEmail(email) to enable/disable the submit button, while the text field in DefaultPasswordRecoveryContent uses FormValidators.email for inline validation. This can lead to cases where the button is enabled but the field shows an error, or the button is disabled with no error shown. Use FormValidators.email.isValid(input: email) to ensure consistent validation across the UI. Note that you will also need to add import FirebaseAuthUIComponents at the top of this file.

Suggested change
private var isValid: Bool {
CommonUtils.isValidEmail(email)
}
private var isValid: Bool {
FormValidators.email.isValid(input: email)
}

Comment on lines +127 to +142
private func sendResetLink() {
guard isValid, !isWorking else { return }
isWorking = true
errorMessage = nil
Task {
defer { isWorking = false }
do {
try await authService.sendPasswordRecoveryEmail(email: email)
sentEmail = email
didSend = true
} catch {
errorMessage = authService.string.localizedErrorMessage(for: error)
reportError?(error)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Capturing email dynamically inside the asynchronous Task can lead to race conditions if the user edits the text field while the request is in flight. If the email is modified before the request completes, the wrong email address will be saved to sentEmail, displaying the incorrect email in the success sheet. Capture the email address locally before starting the Task to ensure consistency.

Suggested change
private func sendResetLink() {
guard isValid, !isWorking else { return }
isWorking = true
errorMessage = nil
Task {
defer { isWorking = false }
do {
try await authService.sendPasswordRecoveryEmail(email: email)
sentEmail = email
didSend = true
} catch {
errorMessage = authService.string.localizedErrorMessage(for: error)
reportError?(error)
}
}
}
private func sendResetLink() {
guard isValid, !isWorking else { return }
let emailToSend = email
isWorking = true
errorMessage = nil
Task {
defer { isWorking = false }
do {
try await authService.sendPasswordRecoveryEmail(email: emailToSend)
sentEmail = emailToSend
didSend = true
} catch {
errorMessage = authService.string.localizedErrorMessage(for: error)
reportError?(error)
}
}
}

.frame(maxWidth: .infinity)
}
}
.disabled(!state.isValid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The submit button is not disabled when state.isLoading is true. Although the screen's submit logic guards against multiple submissions, disabling the button during loading provides a better UX and prevents redundant tap interactions.

Suggested change
.disabled(!state.isValid)
.disabled(!state.isValid || state.isLoading)

.frame(maxWidth: .infinity)
}
.authCTAButtonStyle()
.disabled(!state.isValid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The submit button is not disabled when state.isLoading is true. Disabling the button during loading provides a better UX and prevents redundant tap interactions.

Suggested change
.disabled(!state.isValid)
.disabled(!state.isValid || state.isLoading)

.padding(.vertical, 8)
.frame(maxWidth: .infinity)
}
.disabled(!state.isValid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The submit button is not disabled when state.isLoading is true. Disabling the button during loading provides a better UX and prevents redundant tap interactions.

Suggested change
.disabled(!state.isValid)
.disabled(!state.isValid || state.isLoading)

@demolaf
demolaf marked this pull request as ready for review October 21, 2026 13:20
@demolaf
demolaf force-pushed the auth/phone-content-slots branch from cc01c83 to ca1c05e Compare October 22, 2026 11:45
@demolaf
demolaf force-pushed the auth/email-content-slots branch 2 times, most recently from eb199e8 to d5a1da2 Compare October 22, 2026 13:34
@demolaf
demolaf force-pushed the auth/phone-content-slots branch 2 times, most recently from 5336ee4 to 75d9824 Compare October 22, 2026 13:48
@demolaf
demolaf force-pushed the auth/email-content-slots branch from d5a1da2 to 0720f8c Compare October 22, 2026 13:48
@demolaf
demolaf force-pushed the auth/phone-content-slots branch from 75d9824 to ba7ecda Compare October 22, 2026 14:09
@demolaf
demolaf force-pushed the auth/email-content-slots branch from 0720f8c to cf0ffbe Compare October 22, 2026 14:09
@demolaf
demolaf force-pushed the auth/phone-content-slots branch 4 times, most recently from 9488843 to 63e4ec2 Compare October 29, 2026 12:12
@demolaf
demolaf force-pushed the auth/email-content-slots branch from ca64f9f to 3145692 Compare October 29, 2026 12:16
@demolaf
demolaf force-pushed the auth/phone-content-slots branch from 63e4ec2 to 1a2e734 Compare October 29, 2026 12:26
@demolaf
demolaf force-pushed the auth/email-content-slots branch from 3145692 to e2ef03d Compare October 29, 2026 12:26
…creens

Add EnterPhoneNumberScreen and EnterVerificationCodeScreen, generic over content that receives a PhoneNumberContentState or VerificationCodeContentState. The screens own the input state and the send, verify, resend and change-number actions; the content only draws. DefaultEnterPhoneNumberContent and DefaultEnterVerificationCodeContent hold the stock markup, and the existing views render them through the new screens.

The verification code screen adds a resend button with a 60 second countdown. Verifying now goes through signIn(credentials:), so an MFA challenge reaches the resolution screen instead of being dropped. isLoading also covers sending the code, which previously showed no progress and allowed repeated taps.
Set the in-flight flag before starting each action's Task, so a quick second tap can't send two codes or overlap verify and resend. Only clear the navigator on .signedIn, so an MFA challenge with no handler doesn't drop the user back to the picker.

Type the state closures as @mainactor and give both state structs public initializers, matching AuthMethodPickerState. Clear errorMessage when the input changes, document every field, disable Change number while loading, and show the resend countdown as its own text instead of a concatenated string.
Add EmailAuthScreen, PasswordRecoveryScreen and EmailLinkScreen, which own the email flows' state and logic and hand their content a state struct. The stock markup moves to DefaultEmailAuthContent, DefaultPasswordRecoveryContent and DefaultEmailLinkContent, and the existing views render them through the screens.
Add goToEmailLink and hideSuccess to the state structs, keep the recovery screen when its success sheet is swiped away, handle an opened email link once even when custom content has several root views, move focus to confirm password on Go during sign-up, and align the recovery screen's isLoading with the other screens.
@demolaf
demolaf force-pushed the auth/phone-content-slots branch from 1a2e734 to 3faec1d Compare October 29, 2026 12:41
@demolaf
demolaf force-pushed the auth/email-content-slots branch from e2ef03d to d5cfb5c Compare October 29, 2026 12:41
@demolaf
demolaf force-pushed the auth/phone-content-slots branch from 3faec1d to 12b503d Compare October 30, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant