Skip to content

feat(auth): add content slots to MFA resolution and management screens - #1416

Open
demolaf wants to merge 2 commits into
auth/phone-content-slotsfrom
auth/mfa-content-slots
Open

demolaf wants to merge 2 commits into
auth/phone-content-slotsfrom
auth/mfa-content-slots

Conversation

@demolaf

@demolaf demolaf commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Adds public MFAResolutionScreen and MFAManagementScreen, so apps can draw the MFA sign-in and factor management screens themselves while the library keeps sending codes, resolving sign-in, unenrolling, reauthenticating and reporting errors.

Changes

  • Each screen owns its state and passes its content a MFAResolutionContentState or MFAManagementContentState: Bindings for input, closures for actions, plus isLoading and a localized errorMessage.
  • DefaultMFAResolutionContent and DefaultMFAManagementContent hold the stock markup, and MFAResolutionView and MFAManagementView keep their signatures by rendering them through the screens.
  • MFAManagementScreen keeps the reauthentication modifier outside the content, so the reauth sheet still presents for custom content.
  • isLoading now also covers the AuthService signing in, and the actions ignore repeated taps.

API Usage

AuthPickerView { authenticatedApp }
  .pickerDestination { screen in
    switch screen {
    case let .mfaResolution(mfaRequired):
      MFAResolutionScreen(mfaRequired: mfaRequired) { state in
        TextField("Code", text: state.totpCode)
        Button("Verify", action: state.resolve)
          .disabled(!state.isValid || state.isLoading)
      }
    case .mfaManagement:
      MFAManagementScreen { DefaultMFAManagementContent(state: $0) }
    default:
      DefaultAuthPickerDestination(screen: screen)
    }
  }

Preview

Default resolution, custom resolution, default management, custom management:

Default MFA resolution Custom MFA resolution Default MFA management Custom MFA management


Maintainer note: Fixes internal CPRN-521

@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 Multi-Factor Authentication (MFA) management and resolution screens by separating state management from UI layout into reusable screen and content views, such as DefaultMFAManagementContent and DefaultMFAResolutionContent. The review feedback highlights three key areas for improvement: replacing a risky force cast with a conditional cast to prevent runtime crashes, avoiding a SwiftUI anti-pattern by enumerating collections instead of using indices with self as the identifier in ForEach, and ensuring backward compatibility for older iOS versions by explicitly discarding the argument in onChange closures.

Comment on lines +124 to +129
if factor.factorID == PhoneMultiFactorID {
let phoneInfo = factor as! PhoneMultiFactorInfo
Text("SMS: \(phoneInfo.phoneNumber)")
.authFont(.caption)
.foregroundColor(.secondary)
} else {

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

Using a force cast (as!) can lead to runtime crashes if the underlying type is not what is expected (e.g., during testing or if SDK behavior changes). It is safer to use a conditional cast (as?) combined with the existing if check.

        if factor.factorID == PhoneMultiFactorID, let phoneInfo = factor as? PhoneMultiFactorInfo {
          Text("SMS: \(phoneInfo.phoneNumber)")
            .authFont(.caption)
            .foregroundColor(.secondary)
        } else {

Comment on lines +215 to +218
ForEach(state.hints.indices, id: \.self) { index in
let hint = state.hints[index]
hintSelectionButton(hint: hint, index: index)
}

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

Using state.hints.indices with id: \.self in a ForEach is a SwiftUI anti-pattern. If the collection changes, the indices can refer to incorrect elements or cause out-of-bounds crashes. Since MFAHint conforms to Hashable, you can safely enumerate the collection and use the element itself as the identifier.

      ForEach(Array(state.hints.enumerated()), id: \.element) { index, hint in
        hintSelectionButton(hint: hint, index: index)
      }

Comment on lines +140 to +147
.onChange(of: selectedHintIndex) {
verificationCode = ""
totpCode = ""
verificationId = nil
errorMessage = nil
}
.onChange(of: verificationCode) { errorMessage = nil }
.onChange(of: totpCode) { errorMessage = nil }

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

In SwiftUI, .onChange(of:action:) with a 0-argument closure was introduced in iOS 17.0. If this library supports older iOS versions (e.g., iOS 14-16), using this signature will cause compilation errors. To maintain backward compatibility, explicitly accept and discard the closure's argument using _ in.

Suggested change
.onChange(of: selectedHintIndex) {
verificationCode = ""
totpCode = ""
verificationId = nil
errorMessage = nil
}
.onChange(of: verificationCode) { errorMessage = nil }
.onChange(of: totpCode) { errorMessage = nil }
.onChange(of: selectedHintIndex) { _ in
verificationCode = ""
totpCode = ""
verificationId = nil
errorMessage = nil
}
.onChange(of: verificationCode) { _ in errorMessage = nil }
.onChange(of: totpCode) { _ in errorMessage = nil }

@demolaf
demolaf marked this pull request as ready for review October 21, 2026 13:27
@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/mfa-content-slots branch 2 times, most recently from 396b816 to 198f2c8 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/mfa-content-slots branch from 198f2c8 to 704bd6c 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
Add public MFAResolutionScreen and MFAManagementScreen, which own the screen state and actions and pass MFAResolutionContentState / MFAManagementContentState to consumer content. DefaultMFAResolutionContent and DefaultMFAManagementContent hold the stock markup, and MFAResolutionView and MFAManagementView keep their public signatures by rendering them through the screens. MFAManagementScreen keeps the reauthentication modifier outside the content, so the reauth sheet presents for custom content too.
Wrap each MFA screen's content in one container so the reauthentication sheets and lifecycle modifiers attach once, even when the content returns several views or none. Setting selectedHintIndex resets the challenge again on every tap, a code sent for a hint the user switched away from is dropped, cancelling reauthentication no longer sets errorMessage, and refresh clears it.
@demolaf
demolaf force-pushed the auth/mfa-content-slots branch from 704bd6c to 6bd36dc Compare October 22, 2026 14:10
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