Skip to content

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

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

demolaf wants to merge 3 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.

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.
  • 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)
    }
  }
  .pickerDestination { screen in
    switch screen {
    case .passwordRecovery:
      PasswordRecoveryScreen { state in
        DefaultPasswordRecoveryContent(state: state)
          .background(.yellow.opacity(0.1))
      }
    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
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.
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