diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift index b78576082ff..a4db209c539 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift @@ -161,6 +161,10 @@ public enum AuthServiceError: LocalizedError { /// Phone reauthentication required - user must handle SMS verification flow externally case phoneReauthenticationRequired(context: PhoneReauthContext) + /// Reauthentication requires a second factor. Complete it with `resolveSmsChallenge(hintIndex:)` + /// and `resolveSignIn(code:hintIndex:verificationId:)`, as for sign-in MFA resolution + case multiFactorReauthenticationRequired(MFARequired) + case invalidCredentials(String) case signInFailed(underlying: Error) case accountConflict(AccountConflictContext) @@ -189,6 +193,8 @@ public enum AuthServiceError: LocalizedError { return "Please check your email to verify your identity" case .phoneReauthenticationRequired: return "Please verify your phone number to continue" + case .multiFactorReauthenticationRequired: + return "Please complete two-factor authentication to continue" case let .invalidCredentials(description): return description // Use when failed to sign-in with Firebase diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index df9888f7975..a6ed5beca42 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -73,6 +73,12 @@ public enum AuthenticationFlow { public enum AuthView: Hashable { case passwordRecovery + /// A pushed email and password sign-in screen, ``SignInView`` by default. Reached with + /// ``AuthMethodPickerState/goToEmailSignIn``. + case signIn + /// A pushed email and password sign-up screen, ``SignUpView`` by default. Reached with + /// ``EmailAuthContentState/goToSignUp``. + case signUp case emailLink case updatePassword case mfaEnrollment @@ -159,6 +165,12 @@ public class Navigator { public func clear() { routes.removeAll() } + + /// Removes pushed ``AuthView/signIn`` and ``AuthView/signUp`` screens, which have nothing left + /// to do once the user is signed in. + func removeEmailRoutes() { + routes.removeAll { $0 == .signIn || $0 == .signUp } + } } @MainActor @@ -290,8 +302,10 @@ public final class AuthService { // Clear email link reauth state emailLinkReauth = nil isReauthenticating = false + currentMFAResolver = nil legacySignInRecovery = nil suggestedEmailAddress = nil + navigator.clear() updateAuthenticationState() } @@ -379,8 +393,7 @@ public final class AuthService { } let credential = try await credentialProvider.createAuthCredential() - try await user.reauthenticate(with: credential) - currentUser = auth.currentUser + try await reauthenticate(user: user, credential: credential) } /// Reauthenticates with a pre-obtained credential @@ -391,7 +404,23 @@ public final class AuthService { guard let user = currentUser else { throw AuthServiceError.noCurrentUser } - try await user.reauthenticate(with: credential) + try await reauthenticate(user: user, credential: credential) + } + + /// Throws `multiFactorReauthenticationRequired` when the user has enrolled second factors + private func reauthenticate(user: User, credential: AuthCredential) async throws { + do { + try await user.reauthenticate(with: credential) + } catch let error as NSError + where error.code == AuthErrorCode.secondFactorRequired.rawValue { + guard let resolver = error + .userInfo[AuthErrorUserInfoMultiFactorResolverKey] as? MultiFactorResolver else { + throw error + } + currentMFAResolver = resolver + throw AuthServiceError + .multiFactorReauthenticationRequired(MFARequired(hints: extractMFAHints(from: resolver))) + } currentUser = auth.currentUser } } @@ -616,7 +645,12 @@ public extension AuthService { // - credentialAlreadyInUse: credential is already linked to another account try handleErrorWithConflictCheck(error: error, credential: credential) } else { - // For reauth, just rethrow + // For reauth, just rethrow. The link is consumed even when a second factor is still + // required, so clean up reauth state in that case. + if case AuthServiceError.multiFactorReauthenticationRequired = error { + emailLinkReauth = nil + isReauthenticating = false + } throw error } } @@ -1243,7 +1277,13 @@ extension AuthService { switch option.id { case EmailAuthProviderID: + // An app whose first screen has no email form reached email sign-in through a pushed + // route, so return to that screen for the suggested address. + let usedEmailRoute = navigator.routes.contains { $0 == .signIn || $0 == .signUp } navigator.clear() + if usedEmailRoute { + navigator.push(.signIn) + } case emailLinkSignInMethod: navigator.clear() navigator.push(.emailLink) diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 32852e8c47a..62568eae454 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -17,24 +17,28 @@ import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by -/// `AuthPickerView`'s `pickerContent` and ``AuthPickerView/pickerDestination(_:)`` modifiers. +/// `AuthPickerView`'s `pickerContent`, ``AuthPickerView/pickerDestination(_:)`` and +/// ``AuthPickerView/signedInContent(_:)`` modifiers. /// /// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and -/// account-conflict handling; only the first screen and the pushed screens come from the slots. -/// You don't create it directly. +/// account-conflict handling; only the first screen, the signed-in screen and the pushed screens +/// come from the slots. You don't create it directly. @MainActor public struct AuthPickerContent< Content: View, PickerContent: View, - DestinationContent: View + DestinationContent: View, + SignedInContent: View >: View { init(content: @escaping () -> Content, pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, destination: @escaping (AuthView) -> DestinationContent, + signedInContent: @escaping (SignedInContentState) -> SignedInContent, showsPickerTitle: Bool = true) { self.content = content self.pickerContent = pickerContent self.destination = destination + self.signedInContent = signedInContent self.showsPickerTitle = showsPickerTitle } @@ -42,6 +46,7 @@ public struct AuthPickerContent< private let content: () -> Content private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent + private let signedInContent: (SignedInContentState) -> SignedInContent /// `false` when the whole first screen is custom, so the library's large title doesn't sit /// above the app's own heading. The close button stays so the sheet can still be dismissed. private let showsPickerTitle: Bool @@ -52,7 +57,8 @@ public struct AuthPickerContent< public var body: some View { @Bindable var authService = authService content() - .sheet(isPresented: $authService.isPresented) { + // Reopening the sheet starts from the first screen, not a half-finished pushed one. + .sheet(isPresented: $authService.isPresented, onDismiss: authService.navigator.clear) { @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { root @@ -81,6 +87,13 @@ public struct AuthPickerContent< .environment(authService) } .interactiveDismissDisabled(authService.configuration.interactiveDismissEnabled) + // Close pushed email screens however the user got signed in, including account + // conflicts the library resolves by signing in for them. + .onChange(of: authService.authenticationState) { _, state in + if state == .authenticated { + authService.navigator.removeEmailRoutes() + } + } // Apply account conflict handling at NavigationStack level .accountConflictHandler() // Apply MFA handling at NavigationStack level @@ -89,13 +102,13 @@ public struct AuthPickerContent< } } - /// The sheet's first screen. The slot only replaces the signed-out screen, so signed-in users - /// always reach ``SignedInView`` and the progress overlay always shows while signing in. + /// The sheet's first screen: the signed-in slot for signed-in users, otherwise the picker slot. + /// The progress overlay always shows while signing in, whichever slot is on screen. @ViewBuilder private var root: some View { VStack { if authService.authenticationState == .authenticated { - SignedInView() + SignedInScreen(content: signedInContent) } else { AuthMethodPickerStateReader { state in pickerContent(state) @@ -122,11 +135,12 @@ public struct AuthPickerContent< /// the no-argument `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent - ) -> AuthPickerContent { - AuthPickerContent( + ) -> AuthPickerContent { + AuthPickerContent( content: content, pickerContent: { _ in pickerContent() }, destination: destination, + signedInContent: signedInContent, showsPickerTitle: true ) } @@ -135,11 +149,12 @@ public struct AuthPickerContent< /// ``AuthMethodPickerState``. See the matching `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent - ) -> AuthPickerContent { - AuthPickerContent( + ) -> AuthPickerContent { + AuthPickerContent( content: content, pickerContent: pickerContent, destination: destination, + signedInContent: signedInContent, showsPickerTitle: false ) } @@ -148,11 +163,26 @@ public struct AuthPickerContent< /// ``AuthPickerView/pickerDestination(_:)``. public func pickerDestination( @ViewBuilder _ destination: @escaping (AuthView) -> NewDestinationContent - ) -> AuthPickerContent { - AuthPickerContent( + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: destination, + signedInContent: signedInContent, + showsPickerTitle: showsPickerTitle + ) + } + + /// Replaces the signed-in screen of the authentication sheet. See + /// ``AuthPickerView/signedInContent(_:)``. + public func signedInContent( + @ViewBuilder _ signedInContent: @escaping (SignedInContentState) -> NewSignedInContent + ) -> AuthPickerContent { + AuthPickerContent( content: content, pickerContent: pickerContent, destination: destination, + signedInContent: signedInContent, showsPickerTitle: showsPickerTitle ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index db685ca1fc0..c939d56c910 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -20,8 +20,9 @@ import SwiftUI /// Wraps your app content and presents the authentication sheet whenever /// `AuthService.isPresented` is `true`. /// -/// Use the `pickerContent` modifiers and ``pickerDestination(_:)`` to replace the sheet's first screen or -/// any pushed screen while the library keeps driving navigation, MFA and account conflicts. +/// Use the `pickerContent` modifiers, ``pickerDestination(_:)`` and ``signedInContent(_:)`` to +/// replace the sheet's first screen, any pushed screen or the signed-in screen while the library +/// keeps driving navigation, MFA and account conflicts. /// /// Example: /// ```swift @@ -42,12 +43,14 @@ public struct AuthPickerView: View { AuthPickerContent( content: content, pickerContent: { _ in DefaultAuthPickerContent() }, - destination: { DefaultAuthPickerDestination(screen: $0) } + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) } ) } - /// Replaces the signed-out first screen of the authentication sheet. The library still shows - /// ``SignedInView`` to signed-in users and its progress overlay while signing in. + /// Replaces the signed-out first screen of the authentication sheet. Signed-in users still see + /// ``DefaultSignedInContent`` unless you replace it with ``signedInContent(_:)``, and the + /// progress overlay still shows while signing in. /// /// ```swift /// AuthPickerView { authenticatedApp } @@ -58,11 +61,17 @@ public struct AuthPickerView: View { /// ``` public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent - ) -> AuthPickerContent { + ) -> AuthPickerContent< + Content, + NewPickerContent, + DefaultAuthPickerDestination, + DefaultSignedInContent + > { AuthPickerContent( content: content, pickerContent: { _ in pickerContent() }, - destination: { DefaultAuthPickerDestination(screen: $0) } + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) } ) } @@ -71,9 +80,9 @@ public struct AuthPickerView: View { /// account conflicts and errors are handled the same as the default buttons. /// /// Your screen replaces the logo, email sign-in, terms and the large "Sign in" title (the close - /// button stays), so show your own heading, email entry (for example one built around - /// ``EmailAuthView``) and any legal text you need. To keep the stock screen and only lay out - /// the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// button stays), so show your own heading, email entry (for example ``EmailAuthScreen``) and + /// any legal text you need. To keep the stock screen and only lay out the provider buttons, use + /// ``DefaultAuthPickerContent/init(authMethodPicker:)``. /// /// Example: /// ```swift @@ -84,11 +93,17 @@ public struct AuthPickerView: View { /// ``` public func pickerContent( @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent - ) -> AuthPickerContent { + ) -> AuthPickerContent< + Content, + NewPickerContent, + DefaultAuthPickerDestination, + DefaultSignedInContent + > { AuthPickerContent( content: content, pickerContent: pickerContent, destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: { DefaultSignedInContent(state: $0) }, showsPickerTitle: false ) } @@ -115,12 +130,45 @@ public struct AuthPickerView: View { ) -> AuthPickerContent< Content, DefaultAuthPickerContent, - NewDestinationContent + NewDestinationContent, + DefaultSignedInContent + > { + AuthPickerContent( + content: content, + pickerContent: { _ in DefaultAuthPickerContent() }, + destination: destination, + signedInContent: { DefaultSignedInContent(state: $0) } + ) + } + + /// Replaces the screen the authentication sheet shows to signed-in users. Your content receives + /// a ``SignedInContentState``: the library still signs out, deletes the account behind its + /// confirmation and reauthentication, sends the verification email and navigates. Wrap + /// ``DefaultSignedInContent`` to keep the stock markup. + /// + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .signedInContent { state in + /// MyAccountView( + /// email: state.user?.email, + /// onDeleteAccount: state.deleteAccount, + /// onSignOut: state.signOut + /// ) + /// } + /// ``` + public func signedInContent( + @ViewBuilder _ signedInContent: @escaping (SignedInContentState) -> NewSignedInContent + ) -> AuthPickerContent< + Content, + DefaultAuthPickerContent, + DefaultAuthPickerDestination, + NewSignedInContent > { AuthPickerContent( content: content, pickerContent: { _ in DefaultAuthPickerContent() }, - destination: destination + destination: { DefaultAuthPickerDestination(screen: $0) }, + signedInContent: signedInContent ) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index 79f445e1ae1..c5f863f7d64 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -95,7 +95,9 @@ struct AuthMethodPickerStateReader: View { providers: authService.registeredProviders, select: selectProvider, isEmailLinkEnabled: authService.emailLinkSignInEnabled, - selectEmailLink: authService.selectEmailLink + selectEmailLink: authService.selectEmailLink, + isEmailSignInEnabled: authService.emailPasswordSignInEnabled, + goToEmailSignIn: { authService.navigator.push(.signIn) } )) } @@ -137,15 +139,28 @@ public struct AuthMethodPickerState { /// Starts email-link sign-in, the same as the stock email-link button. public let selectEmailLink: @MainActor () -> Void + /// Whether email and password sign-in is enabled on the ``AuthService``. + public let isEmailSignInEnabled: Bool + + /// Pushes ``AuthView/signIn``, an email and password sign-in screen of its own, for a first + /// screen that shows only provider buttons and a "Continue with email" button. Use it from + /// `AuthPickerView`'s whole-screen `pickerContent`; ``DefaultAuthPickerContent`` already shows + /// the email form. + public let goToEmailSignIn: @MainActor () -> Void + /// Creates a state, for example to preview a custom layout with stub actions. public init(providers: [AuthProviderUI], select: @escaping @MainActor (AuthProviderUI) -> Void, isEmailLinkEnabled: Bool, - selectEmailLink: @escaping @MainActor () -> Void) { + selectEmailLink: @escaping @MainActor () -> Void, + isEmailSignInEnabled: Bool = false, + goToEmailSignIn: @escaping @MainActor () -> Void = {}) { self.providers = providers self.select = select self.isEmailLinkEnabled = isEmailLinkEnabled self.selectEmailLink = selectEmailLink + self.isEmailSignInEnabled = isEmailSignInEnabled + self.goToEmailSignIn = goToEmailSignIn } /// Hands a provider sign-in result to the same handlers the default provider buttons use: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift index 2d5762c3ca7..316b0c722cb 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerDestination.swift @@ -42,6 +42,10 @@ public struct DefaultAuthPickerDestination: View { switch screen { case AuthView.passwordRecovery: PasswordRecoveryView() + case AuthView.signIn: + SignInView() + case AuthView.signUp: + SignUpView() case AuthView.emailLink: EmailLinkView() case AuthView.updatePassword: diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift new file mode 100644 index 00000000000..c6a63a756c7 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailAuthContent.swift @@ -0,0 +1,159 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +private enum FocusableField: Hashable { + case email + case password + case confirmPassword +} + +/// The stock content of ``EmailAuthScreen``. +@MainActor +public struct DefaultEmailAuthContent: View { + public init(state: EmailAuthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + @FocusState private var focus: FocusableField? + private let state: EmailAuthContentState + + public var body: some View { + VStack(spacing: 16) { + AuthTextField( + text: state.email, + label: authService.string.emailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + self.focus = .password + }, + leading: { + Image(systemName: "at") + } + ) + .focused($focus, equals: .email) + .accessibilityIdentifier("email-field") + AuthTextField( + text: state.password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: state.flow == .signUp ? [ + FormValidators.atLeast6Characters + ] : [], + maintainsValidationMessage: state.flow == .signUp, + onSubmit: { _ in + if state.flow == .signIn { + state.signIn() + } else { + focus = .confirmPassword + } + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .password) + .accessibilityIdentifier("password-field") + if state.flow == .signIn { + Button(action: state.goToPasswordRecovery) { + Text(authService.string.passwordButtonLabel) + .authCustomFont(.body, weight: .medium) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .accessibilityIdentifier("password-recovery-button") + } + + if state.flow == .signUp { + AuthTextField( + text: state.confirmPassword, + label: authService.string.confirmPasswordFieldLabel, + prompt: authService.string.confirmPasswordInputLabel, + contentType: .password, + isSecureTextField: true, + validations: [ + FormValidators.confirmPassword(password: state.password.wrappedValue), + ], + maintainsValidationMessage: true, + onSubmit: { _ in + state.signUp() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.go) + .focused($focus, equals: .confirmPassword) + .accessibilityIdentifier("confirm-password-field") + } + + Button { + if state.flow == .signIn { + state.signIn() + } else { + state.signUp() + } + } label: { + if !state.isLoading { + Text( + state.flow == .signIn + ? authService.string.signInWithEmailButtonLabel + : authService.string.signUpWithEmailButtonLabel + ) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + } + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + .accessibilityIdentifier("sign-in-button") + } + Button(action: state.switchFlow) { + HStack(spacing: 4) { + Text( + state.flow == .signIn + ? authService.string.dontHaveAnAccountYetLabel + : authService.string.alreadyHaveAnAccountLabel + ) + .authCustomFont(.body) + .foregroundStyle(Color(.label)) + Text( + state.flow == .signUp + ? authService.string.emailLoginFlowLabel + : authService.string.emailSignUpFlowLabel + ) + .authCustomFont(.body, weight: .medium) + } + } + .accessibilityIdentifier("switch-auth-flow") + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift new file mode 100644 index 00000000000..6955b65dcd6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkContent.swift @@ -0,0 +1,73 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EmailLinkScreen``. +@MainActor +public struct DefaultEmailLinkContent: View { + public init(state: EmailLinkContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: EmailLinkContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.signInLinkEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + .accessibilityIdentifier("email-link-email-field") + Button(action: state.sendSignInLink) { + Text(authService.string.sendEmailLinkButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.signInWithEmailLinkViewTitle) + .safeAreaPadding() + .alert( + authService.string.signInWithEmailLinkViewTitle, + isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.dismissAlert() + } + } + ) + ) { + Button(authService.string.okButtonLabel) {} + } message: { + Text(authService.string.signInWithEmailLinkViewMessage) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift new file mode 100644 index 00000000000..7986df697b3 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailLinkReauthContent.swift @@ -0,0 +1,93 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EmailLinkReauthScreen``. +@MainActor +public struct DefaultEmailLinkReauthContent: View { + public init(state: EmailLinkReauthContentState) { + self.state = state + } + + private let state: EmailLinkReauthContentState + + public var body: some View { + VStack(spacing: 24) { + if state.linkSent { + // "Check your email" state + VStack(spacing: 16) { + Image(systemName: "envelope.open.fill") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + .padding(.top, 32) + + Text("Check Your Email") + .authFont(.title) + .fontWeight(.bold) + + Text("We've sent a verification link to:") + .authFont(.body) + .foregroundStyle(.secondary) + + Text(state.email) + .authFont(.body) + .fontWeight(.medium) + .padding(.horizontal) + + Text("Tap the link in the email to complete reauthentication.") + .authFont(.body) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + .padding(.top, 8) + + Button(action: state.resendLink) { + if state.isLoading { + ProgressView() + .frame(height: 32) + } else { + Text("Resend Email") + .authCustomFont(.body) + .frame(height: 32) + } + } + .buttonStyle(.bordered) + .disabled(state.isLoading) + .padding(.top, 16) + } + } else { + // Loading/sending state + VStack(spacing: 16) { + ProgressView() + .padding(.top, 32) + Text("Sending verification email...") + .authCustomFont(.body) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("Verify Your Identity") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: state.cancel) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift new file mode 100644 index 00000000000..a35fc00fb77 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEmailReauthContent.swift @@ -0,0 +1,93 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EmailReauthScreen``. +@MainActor +public struct DefaultEmailReauthContent: View { + public init(state: EmailReauthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: EmailReauthContentState + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text(authService.string.confirmPasswordTitle) + .authFont(.title) + .fontWeight(.bold) + + Text(authService.string.forSecurityEnterPasswordMessage) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + VStack(spacing: 20) { + Text(authService.string.emailPrefix(email: state.email)) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + AuthTextField( + text: state.password, + label: authService.string.passwordFieldLabel, + prompt: authService.string.passwordInputLabel, + contentType: .password, + isSecureTextField: true, + onSubmit: { _ in + state.verify() + }, + leading: { + Image(systemName: "lock") + } + ) + .submitLabel(.done) + .accessibilityIdentifier("email-reauth-password-field") + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.confirmButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .accessibilityIdentifier("confirm-password-button") + + Button(authService.string.cancelButtonLabel, action: state.cancel) + } + .padding(.horizontal) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift new file mode 100644 index 00000000000..1b5745afa8f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterPhoneNumberContent.swift @@ -0,0 +1,74 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EnterPhoneNumberScreen``. +@MainActor +public struct DefaultEnterPhoneNumberContent: View { + public init(state: PhoneNumberContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneNumberContentState + + public var body: some View { + VStack(spacing: 16) { + Text(authService.string.enterPhoneNumberPlaceholder) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top) + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneFieldLabel, + prompt: authService.string.enterPhoneNumberPlaceholder, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .padding(.top, 8) + + Spacer() + } + .navigationTitle(authService.string.phoneSignInTitle) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift new file mode 100644 index 00000000000..9b724a838d6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultEnterVerificationCodeContent.swift @@ -0,0 +1,91 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``EnterVerificationCodeScreen``. +@MainActor +public struct DefaultEnterVerificationCodeContent: View { + public init(state: VerificationCodeContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: VerificationCodeContentState + + public var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 8) { + Text(authService.string.sentCodeMessage(phoneNumber: state.fullPhoneNumber)) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: state.changeNumber) { + Text(authService.string.changeNumberButtonLabel) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(state.isLoading) + } + .padding(.bottom) + .frame(maxWidth: .infinity, alignment: .leading) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyAndSignInButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + + Button(action: state.resendCode) { + HStack(spacing: 4) { + Text(authService.string.resendCodeLabel) + if state.resendCountdown > 0 { + Text(Duration.seconds(state.resendCountdown), format: .time(pattern: .minuteSecond)) + .monospacedDigit() + } + } + .authCustomFont(.body) + .frame(maxWidth: .infinity) + } + .disabled(state.isLoading || state.resendCountdown > 0) + .accessibilityIdentifier("resend-code-button") + } + + Spacer() + } + .navigationTitle(authService.string.enterVerificationCodeTitle) + .navigationBarTitleDisplayMode(.large) + .padding(.horizontal) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift new file mode 100644 index 00000000000..dd49fbc4033 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAEnrolmentContent.swift @@ -0,0 +1,473 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``MFAEnrolmentScreen``. +@MainActor +public struct DefaultMFAEnrolmentContent: View { + public init(state: MFAEnrollmentContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + @FocusState private var isPhoneNumberFocused: Bool + private let state: MFAEnrollmentContentState + + public var body: some View { + VStack(spacing: 24) { + // Header (only shown when no session is active) + if state.step == .selectFactor { + VStack(spacing: 8) { + Text("Set Up Two-Factor Authentication") + .authFont(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Add an extra layer of security to your account") + .authFont(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + // Factor Type Selection (only if no session started) + if state.step == .selectFactor { + if !state.isMFAEnabled { + VStack(spacing: 12) { + Image(systemName: "lock.slash") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("Multi-Factor Authentication Disabled") + .authFont(.title2) + .fontWeight(.semibold) + + Text( + "MFA is not enabled in the current configuration. Please contact your administrator." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("mfa-disabled-message") + } else if state.allowedFactors.isEmpty { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange) + + Text("No Authentication Methods Available") + .authFont(.title2) + .fontWeight(.semibold) + + Text("No MFA methods are configured as allowed. Please contact your administrator.") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("no-factors-message") + } else { + VStack(alignment: .leading, spacing: 12) { + Text("Choose Authentication Method") + .authFont(.headline) + + Picker("Authentication Method", selection: state.selectedFactorType) { + ForEach(state.allowedFactors, id: \.self) { factorType in + switch factorType { + case .sms: + Image(systemName: "message").tag(SecondFactorType.sms) + case .totp: + Image(systemName: "qrcode").tag(SecondFactorType.totp) + } + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("factor-type-picker") + } + } + } + + // Content based on current state + switch state.step { + case .selectFactor: + initialContent + case .enterPhoneNumber, .enterVerificationCode: + smsEnrollmentContent + case .setUpTOTP: + totpEnrollmentContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + .navigationTitle("Two-Factor Authentication") + .onChange(of: state.step) { isPhoneNumberFocused = false } + } + + private var initialContent: some View { + VStack(spacing: 24) { + // Description based on selected type + if state.selectedFactorType.wrappedValue == .sms { + VStack(spacing: 8) { + Image(systemName: "message.circle") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Authentication") + .authFont(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to your phone number each time you sign in.") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } else { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .authFont(.title2) + .fontWeight(.semibold) + + Text( + "Use an authenticator app like Google Authenticator or Authy to generate verification codes." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + Button { + state.startEnrollment() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Get Started") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("start-enrollment-button") + } + } + + private var smsEnrollmentContent: some View { + VStack(spacing: 24) { + // SMS enrollment steps + if state.step == .enterPhoneNumber { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "phone") + .font(.system(size: 48)) + .foregroundColor(.blue) + + Text("Enter Your Phone Number") + .authFont(.title2) + .fontWeight(.semibold) + + Text("We'll send a verification code to this number") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + AuthTextField( + text: state.phoneNumber, + label: authService.string.phoneNumberFieldLabel, + prompt: authService.string.enterPhoneNumberPrompt, + keyboardType: .phonePad, + contentType: .telephoneNumber, + validations: [ + FormValidators.phoneNumber, + ], + maintainsValidationMessage: true, + onChange: { _ in } + ) { + CountrySelector( + selectedCountry: state.selectedCountry, + enabled: !state.isLoading + ) + } + .focused($isPhoneNumberFocused) + .accessibilityIdentifier("phone-number-field") + + AuthTextField( + text: state.displayName, + label: authService.string.displayNameFieldLabel, + prompt: authService.string.enterDisplayNameForDevicePrompt, + validations: [ + FormValidators.notEmpty(label: "Display name"), + ], + maintainsValidationMessage: true, + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + Button { + state.sendCode() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("send-sms-button") + } + } else if state.step == .enterVerificationCode { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "checkmark.message") + .font(.system(size: 48)) + .foregroundColor(.green) + + Text("Enter Verification Code") + .authFont(.title2) + .fontWeight(.semibold) + + Text("We sent a code to \(state.sentPhoneNumber ?? "your phone")") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("verification-code-field") + + Button { + state.enroll() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("complete-enrollment-button") + + Button { + state.sendCode() + } label: { + Text("Resend Code") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("resend-code-button") + } + } + } + } + + private var totpEnrollmentContent: some View { + VStack(spacing: 16) { + if let totpSecret = state.totpSecret { + VStack(spacing: 16) { + VStack(spacing: 6) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Scan QR Code") + .authFont(.title2) + .fontWeight(.semibold) + + Text("Scan with your authenticator app or tap to open directly") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(nil) + .padding(.horizontal) + } + + // QR Code generated from the otpauth:// URI + if let qrURL = state.totpQRCodeURL, + let qrImage = generateQRCode(from: qrURL.absoluteString) { + Button(action: { + UIApplication.shared.open(qrURL) + }) { + VStack(spacing: 8) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 180, height: 180) + .accessibilityIdentifier("qr-code-image") + + HStack(spacing: 6) { + Image(systemName: "arrow.up.forward.app.fill") + .authFont(.caption) + Text("Tap to open in authenticator app") + .authFont(.caption) + .fontWeight(.medium) + } + .foregroundColor(.blue) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("open-authenticator-button") + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.3)) + .frame(width: 180, height: 180) + .overlay( + VStack { + Image(systemName: "exclamationmark.triangle") + .authFont(.title) + .foregroundColor(.orange) + Text("Unable to generate QR Code") + .authFont(.caption) + } + ) + } + + VStack(spacing: 6) { + Text("Manual Entry Key:") + .authFont(.headline) + + Button(action: state.copySecret) { + HStack { + Text(totpSecret) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.5) + + Spacer() + + Image(systemName: state.didCopySecret ? "checkmark" : "doc.on.doc") + .foregroundColor(state.didCopySecret ? .green : .blue) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .accessibilityIdentifier("totp-secret-key") + + if state.didCopySecret { + Text("Copied to clipboard!") + .authFont(.caption) + .foregroundColor(.green) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: state.didCopySecret) + + AuthTextField( + text: state.displayName, + label: authService.string.displayNameFieldLabel, + prompt: authService.string.enterDisplayNameForAuthenticatorPrompt, + validations: [ + FormValidators.notEmpty(label: "Display name"), + ], + maintainsValidationMessage: true, + leading: { + Image(systemName: "person") + } + ) + .accessibilityIdentifier("display-name-field") + + VerificationCodeInputField( + code: state.totpCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-code-field") + + Button { + state.enroll() + } label: { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Setup") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("complete-enrollment-button") + } + } + } + } + + private func generateQRCode(from string: String) -> UIImage? { + let data = Data(string.utf8) + + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(data, forKey: "inputMessage") + filter.setValue("H", forKey: "inputCorrectionLevel") + + guard let ciImage = filter.outputImage else { return nil } + + // Scale up the QR code for better quality + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaledImage = ciImage.transformed(by: transform) + + let context = CIContext() + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift new file mode 100644 index 00000000000..0b749689d49 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAManagementContent.swift @@ -0,0 +1,166 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import SwiftUI + +/// The stock content of ``MFAManagementScreen``. +@MainActor +public struct DefaultMFAManagementContent: View { + public init(state: MFAManagementContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: MFAManagementContentState + + public var body: some View { + VStack(spacing: 20) { + // Title section + VStack { + Text("Two-Factor Authentication") + .authFont(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Manage your authentication methods") + .authFont(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + if state.enrolledFactors.isEmpty { + // No factors enrolled + VStack(spacing: 16) { + Image(systemName: "shield.slash") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("No Authentication Methods") + .authFont(.title2) + .fontWeight(.semibold) + + Text( + "Set up two-factor authentication to add an extra layer of security to your account." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button { + state.goToEnrollment() + } label: { + Text("Set Up Two-Factor Authentication") + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("setup-mfa-button") + } + } else { + // Show enrolled factors + VStack(alignment: .leading, spacing: 16) { + Text("Enrolled Methods") + .authFont(.headline) + .padding(.horizontal) + + ForEach(state.enrolledFactors) { factor in + factorRow(factor: factor) + } + + Divider() + .padding(.horizontal) + + Button("Add Another Method") { + state.goToEnrollment() + } + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + .accessibilityIdentifier("add-mfa-method-button") + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + } + + @ViewBuilder + private func factorRow(factor: MultiFactorInfo) -> some View { + HStack { + // Factor type icon + Group { + if factor.factorID == PhoneMultiFactorID { + Image(systemName: "message") + .foregroundColor(.blue) + } else { + Image(systemName: "qrcode") + .foregroundColor(.green) + } + } + .authFont(.title2) + + VStack(alignment: .leading, spacing: 4) { + Text(factor.displayName ?? authService.string.unnamedMethodLabel) + .authFont(.headline) + + if factor.factorID == PhoneMultiFactorID { + let phoneInfo = factor as! PhoneMultiFactorInfo + Text("SMS: \(phoneInfo.phoneNumber)") + .authFont(.caption) + .foregroundColor(.secondary) + } else { + Text("Authenticator App") + .authFont(.caption) + .foregroundColor(.secondary) + } + + Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") + .authFont(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Remove") { + state.unenroll(factor.uid) + } + .buttonStyle(.bordered) + .foregroundColor(.red) + .disabled(state.isLoading) + .accessibilityIdentifier("remove-factor-\(factor.uid)") + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + } +} + +// MARK: - Date Formatter Extension + +private extension DateFormatter { + static let shortDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter + }() +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift new file mode 100644 index 00000000000..8297e047fd1 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultMFAResolutionContent.swift @@ -0,0 +1,284 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``MFAResolutionScreen``. +@MainActor +public struct DefaultMFAResolutionContent: View { + public init(state: MFAResolutionContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: MFAResolutionContentState + + private var selectedHint: MFAHint? { + guard state.hints.indices.contains(state.selectedHintIndex.wrappedValue) else { + return nil + } + return state.hints[state.selectedHintIndex.wrappedValue] + } + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "lock.shield") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Two-Factor Authentication") + .authFont(.largeTitle) + .fontWeight(.bold) + .accessibilityIdentifier("mfa-resolution-title") + + Text("Complete sign-in with your second factor") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + + // MFA Hints Selection (if multiple available) + if state.hints.count > 1 { + mfaHintsSelectionView + } + + // Resolution Content + if let hint = selectedHint { + resolutionContent(for: hint) + } + + // Action buttons + VStack(spacing: 12) { + // Complete Resolution Button + Button(action: state.resolve) { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Complete Sign-In") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(state.isLoading || !state.isValid) + .accessibilityIdentifier("complete-resolution-button") + + // Cancel Button + Button(action: state.cancel) { + Text("Cancel") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityIdentifier("cancel-button") + } + .padding(.horizontal) + } + .padding(.vertical, 20) + } + + @ViewBuilder + private func resolutionContent(for hint: MFAHint) -> some View { + switch hint { + case let .phone(displayName, _, phoneNumber): + phoneResolutionContent(displayName: displayName, phoneNumber: phoneNumber) + case let .totp(displayName, _): + totpResolutionContent(displayName: displayName) + } + } + + @ViewBuilder + private func phoneResolutionContent(displayName _: String?, phoneNumber: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "message.circle.fill") + .font(.system(size: 40)) + .foregroundColor(.blue) + + Text("SMS Verification") + .authFont(.title2) + .fontWeight(.semibold) + + if let phoneNumber = phoneNumber { + Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") + .authFont(.body) + .foregroundColor(.secondary) + } else { + Text("We'll send a verification code to your phone") + .authFont(.body) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // Send SMS button (if verification ID not yet obtained) + if !state.isCodeSent { + Button(action: state.sendCode) { + HStack { + if state.isLoading { + ProgressView() + .scaleEffect(0.8) + } + Text("Send Code") + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .disabled(state.isLoading) + .padding(.horizontal) + .accessibilityIdentifier("send-sms-button") + } else { + // Verification code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .authFont(.headline) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("sms-verification-code-field") + } + .padding(.horizontal) + } + } + } + + @ViewBuilder + private func totpResolutionContent(displayName: String?) -> some View { + VStack(spacing: 16) { + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.green) + + Text("Authenticator App") + .authFont(.title2) + .fontWeight(.semibold) + + Text("Enter the 6-digit code from your authenticator app") + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let displayName = displayName { + Text(authService.string.accountPrefix(displayName: displayName)) + .authFont(.caption) + .foregroundColor(.secondary) + } + } + .padding(.horizontal) + + // TOTP code input + VStack(alignment: .leading, spacing: 8) { + Text("Verification Code") + .authFont(.headline) + + VerificationCodeInputField( + code: state.totpCode, + validations: [FormValidators.verificationCode], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("totp-verification-code-field") + } + .padding(.horizontal) + } + } + + @ViewBuilder + private var mfaHintsSelectionView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Choose verification method:") + .authFont(.headline) + .padding(.horizontal) + + ForEach(state.hints.indices, id: \.self) { index in + let hint = state.hints[index] + hintSelectionButton(hint: hint, index: index) + } + } + } + + @ViewBuilder + private func hintSelectionButton(hint: MFAHint, index: Int) -> some View { + let isSelected = state.selectedHintIndex.wrappedValue == index + Button(action: { + state.selectedHintIndex.wrappedValue = index + }) { + HStack { + Image(systemName: hint.isPhoneHint ? "message.circle" : "qrcode") + .foregroundColor(.blue) + + VStack(alignment: .leading) { + Text(hintDisplayName(for: hint)) + .authFont(.body) + .foregroundColor(.primary) + + hintSubtitle(for: hint) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } + } + .padding() + .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal) + .accessibilityIdentifier("hint-\(index)") + } + + private func hintDisplayName(for hint: MFAHint) -> String { + hint.isPhoneHint ? "SMS" : "Authenticator App" + } + + @ViewBuilder + private func hintSubtitle(for hint: MFAHint) -> some View { + if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { + Text("••••••\(String(phone.suffix(4)))") + .authFont(.caption) + .foregroundColor(.secondary) + } + } +} + +// Helper extension for MFAHint +private extension MFAHint { + var isPhoneHint: Bool { + switch self { + case .phone: + return true + case .totp: + return false + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift new file mode 100644 index 00000000000..f2fcbdfa844 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPasswordRecoveryContent.swift @@ -0,0 +1,92 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``PasswordRecoveryScreen``. +@MainActor +public struct DefaultPasswordRecoveryContent: View { + public init(state: PasswordRecoveryContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PasswordRecoveryContentState + + public var body: some View { + VStack(spacing: 24) { + AuthTextField( + text: state.email, + label: authService.string.passwordRecoveryEmailFieldLabel, + prompt: authService.string.emailInputLabel, + keyboardType: .emailAddress, + contentType: .emailAddress, + validations: [ + FormValidators.email, + ], + leading: { + Image(systemName: "at") + } + ) + Button(action: state.sendResetLink) { + Text(authService.string.forgotPasswordInputLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .disabled(!state.isValid) + .frame(maxWidth: .infinity) + .authCTAButtonStyle() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(authService.string.passwordRecoveryTitle) + .safeAreaPadding() + .sheet(isPresented: Binding( + get: { state.didSend }, + set: { isPresented in + if !isPresented { + state.hideSuccess() + } + } + )) { + successSheet + } + } + + @ViewBuilder + @MainActor + private var successSheet: some View { + VStack { + Text(authService.string.passwordRecoveryEmailSentTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + Text(authService.string.passwordRecoveryHelperMessage) + .authCustomFont(.body) + .padding() + + Divider() + + Text(String(format: authService.string.passwordRecoveryEmailSentMessage, state.sentEmail)) + .authCustomFont(.body) + .padding() + + Divider() + + Button(authService.string.okButtonLabel, action: state.dismissSuccess) + .padding() + } + .padding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift new file mode 100644 index 00000000000..137e5a7d2b9 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultPhoneReauthContent.swift @@ -0,0 +1,133 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The stock content of ``PhoneReauthScreen``. +@MainActor +public struct DefaultPhoneReauthContent: View { + public init(state: PhoneReauthContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: PhoneReauthContentState + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "phone.circle.fill") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text(authService.string.verifyPhoneNumberTitle) + .authFont(.title) + .fontWeight(.bold) + + Text(authService.string.forSecurityVerifyPhoneMessage) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + + if !state.codeSent { + // Initial state - sending SMS + VStack(spacing: 20) { + Text(authService.string.sendVerificationCodeToPhonePrefix) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(state.phoneNumber) + .authFont(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + Button(action: state.sendCode) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.sendVerificationCodeButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(state.isLoading) + .accessibilityIdentifier("send-verification-code-button") + Button(authService.string.cancelButtonLabel, action: state.cancel) + } + .padding(.horizontal) + } else { + // Enter verification code + VStack(spacing: 20) { + Text(authService.string.enterSixDigitCodeSentToPrefix) + .authFont(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(state.phoneNumber) + .authFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + VerificationCodeInputField( + code: state.verificationCode, + validations: [ + FormValidators.verificationCode, + ], + maintainsValidationMessage: true + ) + .accessibilityIdentifier("verification-code-field") + + Button(action: state.verify) { + if state.isLoading { + ProgressView() + .frame(height: 32) + .frame(maxWidth: .infinity) + } else { + Text(authService.string.verifyButtonLabel) + .frame(height: 32) + .frame(maxWidth: .infinity) + } + } + .authCTAButtonStyle() + .disabled(!state.isValid || state.isLoading) + .accessibilityIdentifier("verify-button") + + Button(action: state.sendCode) { + Text("Resend Code") + .frame(height: 32) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(state.isLoading) + .accessibilityIdentifier("resend-code-button") + + Button(authService.string.cancelButtonLabel, action: state.cancel) + } + .padding(.horizontal) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift new file mode 100644 index 00000000000..c2ddf3122f2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultSignedInContent.swift @@ -0,0 +1,94 @@ +// Copyright 2025 Google LLC +// +// 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. + +import SwiftUI + +/// The stock signed-in screen, the default content of ``AuthPickerView/signedInContent(_:)``. +/// Wrap it to keep the stock markup. +@MainActor +public struct DefaultSignedInContent: View { + public init(state: SignedInContentState) { + self.state = state + } + + @Environment(AuthService.self) private var authService + private let state: SignedInContentState + + public var body: some View { + VStack { + Text(authService.string.signedInTitle) + .authFont(.largeTitle) + .fontWeight(.bold) + .padding() + .accessibilityIdentifier("signed-in-text") + Text( + "\(state.user?.email ?? state.user?.displayName ?? state.user?.phoneNumber ?? "")" + ) + .authCustomFont(.body) + if !state.isEmailVerified { + Button(action: state.verifyEmail) { + Text(authService.string.sendEmailVerificationButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("verify-email-button") + } + Button(action: state.updatePassword) { + Text(authService.string.updatePasswordButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("update-password-button") + + if state.isMFAEnabled { + Button(action: state.goToMFAManagement) { + Text(authService.string.manageTwoFactorAuthenticationLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("mfa-management-button") + } + + Button(action: state.deleteAccount) { + Text(authService.string.deleteAccountButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("delete-account-button") + + Button(action: state.signOut) { + Text(authService.string.signOutButtonLabel) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .authCTAButtonStyle() + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("sign-out-button") + } + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift new file mode 100644 index 00000000000..f4b74e297a4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthScreen.swift @@ -0,0 +1,264 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the email and password form, passed to ``EmailAuthScreen``'s +/// content. +public struct EmailAuthContentState { + /// The email address. The screen fills it in from ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// The password. + public let password: Binding + + /// The repeated password, checked against ``password`` when ``flow`` is + /// ``AuthenticationFlow/signUp``. + public let confirmPassword: Binding + + /// Signs in with ``email`` and ``password``, routing MFA and account conflicts to their + /// handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signIn: @MainActor () -> Void + + /// Creates an account with ``email`` and ``password``, routing MFA and account conflicts to + /// their handlers. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let signUp: @MainActor () -> Void + + /// Toggles ``flow`` between sign-in and sign-up, with animation. On a screen with a fixed + /// flow it navigates instead: sign-up pops back and sign-in pushes ``AuthView/signUp``. + public let switchFlow: @MainActor () -> Void + + /// Pushes ``AuthView/signUp``, for a sign-up screen of its own instead of toggling ``flow``. + public let goToSignUp: @MainActor () -> Void + + /// Pushes ``AuthView/passwordRecovery``. + public let goToPasswordRecovery: @MainActor () -> Void + + /// Starts email-link sign-in, the same as the stock email-link button. + public let goToEmailLink: @MainActor () -> Void + + /// Whether the form signs in or signs up. Shared with ``AuthService/authenticationFlow`` + /// unless the screen was created with a fixed flow. + public let flow: AuthenticationFlow + + /// Whether the fields are valid for ``flow``: a valid email and a non-empty password to sign + /// in; a valid email, a password of at least 6 characters and a matching ``confirmPassword`` + /// to sign up. + public let isValid: Bool + + /// Whether a sign-in or sign-up is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when a field or ``flow`` + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + password: Binding, + confirmPassword: Binding, + signIn: @escaping @MainActor () -> Void, + signUp: @escaping @MainActor () -> Void, + switchFlow: @escaping @MainActor () -> Void, + goToSignUp: @escaping @MainActor () -> Void, + goToPasswordRecovery: @escaping @MainActor () -> Void, + goToEmailLink: @escaping @MainActor () -> Void, + flow: AuthenticationFlow, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.password = password + self.confirmPassword = confirmPassword + self.signIn = signIn + self.signUp = signUp + self.switchFlow = switchFlow + self.goToSignUp = goToSignUp + self.goToPasswordRecovery = goToPasswordRecovery + self.goToEmailLink = goToEmailLink + self.flow = flow + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email and password form, with its content drawn by you. +/// +/// The screen signs in and signs up, switches the flow, navigates, and routes MFA, account +/// conflicts and errors. Your content only draws the UI from the ``EmailAuthContentState`` it +/// receives. Wrap ``DefaultEmailAuthContent`` to keep the stock markup. +/// +/// `AuthPickerView`'s `pickerContent { state in ... }` replaces the whole first screen, so add +/// your own provider buttons calling `state.select`, or the stock ones with +/// ``DefaultAuthMethodPicker``. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerContent { +/// ScrollView { +/// VStack { +/// 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) +/// } +/// DefaultAuthMethodPicker() +/// } +/// } +/// } +/// ``` +@MainActor +public struct EmailAuthScreen: View { + /// Creates the screen. Leave `flow` `nil` to follow ``AuthService/authenticationFlow`` and + /// toggle it in place, as the stock first screen does. + /// + /// Pass a `flow` to fix it and make ``EmailAuthContentState/switchFlow`` navigate instead: + /// `.signIn` pushes ``AuthView/signUp``, so a first screen fixed to `.signIn` gives the stock + /// "Sign up" link its own sign-up screen, as ``SignInView`` does for ``AuthView/signIn``. Use + /// `.signUp` only for an ``AuthView/signUp`` destination, where `switchFlow` pops back to the + /// screen that pushed it. + public init(flow: AuthenticationFlow? = nil, + @ViewBuilder content: @escaping (EmailAuthContentState) -> Content) { + fixedFlow = flow + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let fixedFlow: AuthenticationFlow? + private let content: (EmailAuthContentState) -> Content + + public var body: some View { + content(EmailAuthContentState( + email: $email, + password: $password, + confirmPassword: $confirmPassword, + signIn: signIn, + signUp: signUp, + switchFlow: switchFlow, + goToSignUp: { authService.navigator.push(.signUp) }, + goToPasswordRecovery: { authService.navigator.push(.passwordRecovery) }, + goToEmailLink: { authService.selectEmailLink() }, + flow: flow, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onChange(of: password) { errorMessage = nil } + .onChange(of: confirmPassword) { errorMessage = nil } + .onChange(of: authService.authenticationFlow) { errorMessage = nil } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var flow: AuthenticationFlow { + fixedFlow ?? authService.authenticationFlow + } + + private var isValid: Bool { + return if flow == .signIn { + FormValidators.email.isValid(input: email) && !password.isEmpty + } else { + FormValidators.email.isValid(input: email) && + FormValidators.atLeast6Characters.isValid(input: password) && + FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func switchFlow() { + switch fixedFlow { + case .signUp: + authService.navigator.pop() + return + case .signIn: + authService.navigator.push(.signUp) + return + case nil: + break + } + withAnimation { + authService.authenticationFlow = + authService.authenticationFlow == .signIn ? .signUp : .signIn + } + } + + private func signIn() { + submit { [email, password] in + try await authService.signIn(email: email, password: password) + } + } + + private func signUp() { + submit { [email, password] in + try await authService.createUser(email: email, password: password) + } + } + + private func submit(_ attempt: @escaping @MainActor () async throws -> SignInOutcome) { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + if case let .mfaRequired(mfaInfo) = try await attempt() { + mfaHandler?(mfaInfo) + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + reportError?(error) + // The legacy sign-in recovery sheet explains this failure, so there is no message. + if case .legacySignInRecoveryPresented = error as? AuthServiceError { + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift index 7d06240f4a1..96cc6a69610 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailAuthView.swift @@ -18,232 +18,17 @@ // // Created by Russell Wheatley on 20/03/2025. // -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case email - case password - case confirmPassword -} - @MainActor public struct EmailAuthView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.mfaHandler) private var mfaHandler - @Environment(\.reportError) private var reportError - - @State private var email = "" - @State private var password = "" - @State private var confirmPassword = "" - - @FocusState private var focus: FocusableField? - public init() {} - - private var isValid: Bool { - return if authService.authenticationFlow == .signIn { - FormValidators.email.isValid(input: email) && !password.isEmpty - } else { - FormValidators.email.isValid(input: email) && - FormValidators.atLeast6Characters.isValid(input: password) && - FormValidators.confirmPassword(password: password).isValid(input: confirmPassword) - } - } - - private func signInWithEmailPassword() async throws { - do { - let outcome = try await authService.signIn(email: email, password: password) - - // Handle MFA at view level - if case let .mfaRequired(mfaInfo) = outcome, - let onMFA = mfaHandler { - onMFA(mfaInfo) - return - } - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - if case .legacySignInRecoveryPresented = error as? AuthServiceError { - return - } - - throw error - } - } - - private func createUserWithEmailPassword() async throws { - do { - let outcome = try await authService.createUser(email: email, password: password) - - // Handle MFA at view level - if case let .mfaRequired(mfaInfo) = outcome, - let onMFA = mfaHandler { - onMFA(mfaInfo) - return - } - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } } extension EmailAuthView: View { public var body: some View { - VStack(spacing: 16) { - AuthTextField( - text: $email, - label: authService.string.emailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - self.focus = .password - }, - leading: { - Image(systemName: "at") - } - ) - .focused($focus, equals: .email) - .accessibilityIdentifier("email-field") - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: authService.authenticationFlow == .signUp ? [ - FormValidators.atLeast6Characters - ] : [], - maintainsValidationMessage: authService.authenticationFlow == .signUp, - onSubmit: { _ in - Task { try await signInWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .password) - .accessibilityIdentifier("password-field") - if authService.authenticationFlow == .signIn { - Button { - authService.navigator.push(.passwordRecovery) - } label: { - Text(authService.string.passwordButtonLabel) - .authCustomFont(.body, weight: .medium) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .accessibilityIdentifier("password-recovery-button") - } - - if authService.authenticationFlow == .signUp { - AuthTextField( - text: $confirmPassword, - label: authService.string.confirmPasswordFieldLabel, - prompt: authService.string.confirmPasswordInputLabel, - contentType: .password, - isSecureTextField: true, - validations: [ - FormValidators.confirmPassword(password: password), - ], - maintainsValidationMessage: true, - onSubmit: { _ in - Task { try await createUserWithEmailPassword() } - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.go) - .focused($focus, equals: .confirmPassword) - .accessibilityIdentifier("confirm-password-field") - } - - Button(action: { - Task { - if authService.authenticationFlow == .signIn { - try await signInWithEmailPassword() - } else { - try await createUserWithEmailPassword() - } - } - }) { - if authService.authenticationState != .authenticating { - Text( - authService.authenticationFlow == .signIn - ? authService.string.signInWithEmailButtonLabel - : authService.string.signUpWithEmailButtonLabel - ) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - } - .disabled(!isValid) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - .accessibilityIdentifier("sign-in-button") - } - Button(action: { - withAnimation { - authService.authenticationFlow = - authService - .authenticationFlow == .signIn ? .signUp : .signIn - } - }) { - HStack(spacing: 4) { - Text( - authService - .authenticationFlow == .signIn - ? authService.string.dontHaveAnAccountYetLabel - : authService.string.alreadyHaveAnAccountLabel - ) - .authCustomFont(.body) - .foregroundStyle(Color(.label)) - Text( - authService.authenticationFlow == .signUp - ? authService.string.emailLoginFlowLabel - : authService.string.emailSignUpFlowLabel - ) - .authCustomFont(.body, weight: .medium) - } - } - .accessibilityIdentifier("switch-auth-flow") - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailAuthScreen { DefaultEmailAuthContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift new file mode 100644 index 00000000000..bc0bbee939d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthScreen.swift @@ -0,0 +1,147 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the email link reauthentication prompt, passed to +/// ``EmailLinkReauthScreen``'s content. +/// +/// The screen sends the first link when it appears and completes reauthentication when the app +/// opens the link. +public struct EmailLinkReauthContentState { + /// Sends a new link to ``email``. Does nothing while ``isLoading`` is `true`. + public let resendLink: @MainActor () -> Void + + /// Cancels reauthentication and dismisses the prompt. + public let cancel: @MainActor () -> Void + + /// The email address of the signed-in user. + public let email: String + + /// Whether a link was sent, so the content should ask the user to open it. + public let linkSent: Bool + + /// Whether a link is being sent. + public let isLoading: Bool + + /// The localized message of the last failed send or link sign-in. It clears when the next + /// attempt starts. The prompt's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(resendLink: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + email: String, + linkSent: Bool, + isLoading: Bool, + errorMessage: String?) { + self.resendLink = resendLink + self.cancel = cancel + self.email = email + self.linkSent = linkSent + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email link reauthentication prompt, with its content drawn by you. +/// +/// The screen sends the link, reauthenticates when the app opens it, resolves a second factor, +/// and reports errors. Your content only draws the UI from the ``EmailLinkReauthContentState`` +/// it receives. Apps usually supply content with `reauthContent(_:)` rather than creating this +/// screen. +@MainActor +public struct EmailLinkReauthScreen: View { + public init(email: String, + coordinator: ReauthenticationCoordinator, + @ViewBuilder content: @escaping (EmailLinkReauthContentState) -> Content) { + self.email = email + self.coordinator = coordinator + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var linkSent = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var error: AlertError? + @State private var mfaRequired: MFARequired? + + private let email: String + private let coordinator: ReauthenticationCoordinator + private let content: (EmailLinkReauthContentState) -> Content + + public var body: some View { + NavigationStack { + content(EmailLinkReauthContentState( + resendLink: { Task { await sendLink() } }, + cancel: { coordinator.reauthCancelled() }, + email: email, + linkSent: linkSent, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onOpenURL { url in + handleReauthURL(url) + } + .task { + await sendLink() + } + .mfaReauthResolution($mfaRequired, coordinator: coordinator) + } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } + + private func sendLink() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + try await authService.sendEmailSignInLink(email: email, isReauth: true) + linkSent = true + } catch { + report(error) + } + } + + private func handleReauthURL(_ url: URL) { + errorMessage = nil + Task { + do { + try await authService.handleSignInLink(url: url) + coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + } catch { + report(error) + } + } + } + + private func report(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift index fc896270f90..7a5a71dd923 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkReauthView.swift @@ -12,144 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct EmailLinkReauthView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - let email: String let coordinator: ReauthenticationCoordinator - - @State private var emailSent = false - @State private var isLoading = false - @State private var error: AlertError? - - private func sendEmailLink() async { - isLoading = true - do { - try await authService.sendEmailSignInLink(email: email, isReauth: true) - emailSent = true - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - - private func handleReauthURL(_ url: URL) { - Task { @MainActor in - do { - try await authService.handleSignInLink(url: url) - coordinator.reauthCompleted() - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - } - } - } } extension EmailLinkReauthView: View { public var body: some View { - NavigationStack { - VStack(spacing: 24) { - if emailSent { - // "Check your email" state - VStack(spacing: 16) { - Image(systemName: "envelope.open.fill") - .font(.system(size: 60)) - .foregroundColor(.accentColor) - .padding(.top, 32) - - Text("Check Your Email") - .authFont(.title) - .fontWeight(.bold) - - Text("We've sent a verification link to:") - .authFont(.body) - .foregroundStyle(.secondary) - - Text(email) - .authFont(.body) - .fontWeight(.medium) - .padding(.horizontal) - - Text("Tap the link in the email to complete reauthentication.") - .authFont(.body) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal, 32) - .padding(.top, 8) - - Button { - Task { - await sendEmailLink() - } - } label: { - if isLoading { - ProgressView() - .frame(height: 32) - } else { - Text("Resend Email") - .authCustomFont(.body) - .frame(height: 32) - } - } - .buttonStyle(.bordered) - .disabled(isLoading) - .padding(.top, 16) - } - } else { - // Loading/sending state - VStack(spacing: 16) { - ProgressView() - .padding(.top, 32) - Text("Sending verification email...") - .authCustomFont(.body) - .foregroundStyle(.secondary) - } - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle("Verify Your Identity") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - coordinator.reauthCancelled() - } - } - } - .onOpenURL { url in - handleReauthURL(url) - } - .task { - await sendEmailLink() - } + EmailLinkReauthScreen(email: email, coordinator: coordinator) { + DefaultEmailLinkReauthContent(state: $0) } - .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift new file mode 100644 index 00000000000..11577d6902d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkScreen.swift @@ -0,0 +1,173 @@ +// Copyright 2025 Google LLC +// +// 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. + +import SwiftUI + +/// The state and actions of the email-link screen, passed to ``EmailLinkScreen``'s content. +public struct EmailLinkContentState { + /// The email address to send the sign-in link to. The screen fills it in from + /// ``AuthService/suggestedEmailAddress``. + public let email: Binding + + /// Sends a sign-in link to ``email``, then sets ``didSend``. Does nothing unless ``isValid`` is + /// `true` and ``isLoading`` is `false`. + public let sendSignInLink: @MainActor () -> Void + + /// Clears ``didSend``. + public let dismissAlert: @MainActor () -> Void + + /// Whether a sign-in link was sent and ``dismissAlert`` hasn't been called since. + public let didSend: Bool + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a link is being sent or handled, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed send or link sign-in. It clears when the email + /// changes or the next attempt starts. The sheet's error alert still shows for the same + /// failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendSignInLink: @escaping @MainActor () -> Void, + dismissAlert: @escaping @MainActor () -> Void, + didSend: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendSignInLink = sendSignInLink + self.dismissAlert = dismissAlert + self.didSend = didSend + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The email-link screen, with its content drawn by you. +/// +/// The screen sends the link, signs in when the app opens it, and routes account conflicts and +/// errors. Your content only draws the UI from the ``EmailLinkContentState`` it receives. Wrap +/// ``DefaultEmailLinkContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .emailLink: +/// EmailLinkScreen { state in +/// TextField("Email", text: state.email) +/// Button("Send link", action: state.sendSignInLink) +/// .disabled(!state.isValid || state.isLoading) +/// if state.didSend { +/// Text("Check your inbox") +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EmailLinkScreen: View { + public init(@ViewBuilder content: @escaping (EmailLinkContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var isWorking = false + @State private var isHandlingLink = false + @State private var errorMessage: String? + + private let content: (EmailLinkContentState) -> Content + + public var body: some View { + content(EmailLinkContentState( + email: $email, + sendSignInLink: sendSignInLink, + dismissAlert: { didSend = false }, + didSend: didSend, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + .onOpenURL { url in + // Content with several root views gets this handler once per view; the link is one-time, + // so only the first one handles it. + guard !isHandlingLink else { return } + isHandlingLink = true + errorMessage = nil + Task { + defer { isHandlingLink = false } + do { + try await authService.handleSignInLink(url: url) + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + .onAppear { + if let suggestedEmailAddress = authService.suggestedEmailAddress { + email = suggestedEmailAddress + } + } + .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in + if let suggestedEmailAddress { + email = suggestedEmailAddress + } + } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private var isLoading: Bool { + isWorking || isHandlingLink || authService.authenticationState == .authenticating + } + + 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) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift index c64c86e5701..c5b8e776aa0 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailLinkView.swift @@ -12,106 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct EmailLinkView { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var email = "" - @State private var showAlert = false - public init() {} - - private func sendEmailLink() async throws { - do { - try await authService.sendEmailSignInLink(email: email) - showAlert = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } } extension EmailLinkView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.signInLinkEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - .accessibilityIdentifier("email-link-email-field") - Button { - Task { - try await sendEmailLink() - authService.emailLink = email - } - } label: { - Text(authService.string.sendEmailLinkButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!CommonUtils.isValidEmail(email)) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.signInWithEmailLinkViewTitle) - .safeAreaPadding() - .alert( - authService.string.signInWithEmailLinkViewTitle, - isPresented: $showAlert - ) { - Button(authService.string.okButtonLabel) { - showAlert = false - } - } message: { - Text(authService.string.signInWithEmailLinkViewMessage) - } - .onOpenURL { url in - Task { - do { - try await authService.handleSignInLink(url: url) - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - } - .onAppear { - if let suggestedEmailAddress = authService.suggestedEmailAddress { - email = suggestedEmailAddress - } - } - .onChange(of: authService.suggestedEmailAddress) { _, suggestedEmailAddress in - if let suggestedEmailAddress { - email = suggestedEmailAddress - } - } + EmailLinkScreen { DefaultEmailLinkContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift new file mode 100644 index 00000000000..b107e8cbccb --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthScreen.swift @@ -0,0 +1,136 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the password reauthentication prompt, passed to +/// ``EmailReauthScreen``'s content. +public struct EmailReauthContentState { + /// The password the user typed. + public let password: Binding + + /// Reauthenticates with ``email`` and ``password``, pushing second-factor resolution when the + /// user has MFA. Does nothing unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Cancels reauthentication and dismisses the prompt. + public let cancel: @MainActor () -> Void + + /// The email address of the signed-in user. + public let email: String + + /// Whether ``password`` is not empty. + public let isValid: Bool + + /// Whether a reauthentication attempt is in flight. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the password changes or + /// the next attempt starts. The prompt's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(password: Binding, + verify: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + email: String, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.password = password + self.verify = verify + self.cancel = cancel + self.email = email + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The password reauthentication prompt, with its content drawn by you. +/// +/// The screen reauthenticates, resolves a second factor, and reports errors. Your content only +/// draws the UI from the ``EmailReauthContentState`` it receives. Apps usually supply content +/// with `reauthContent(_:)` rather than creating this screen. +@MainActor +public struct EmailReauthScreen: View { + public init(email: String, + coordinator: ReauthenticationCoordinator, + @ViewBuilder content: @escaping (EmailReauthContentState) -> Content) { + self.email = email + self.coordinator = coordinator + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var error: AlertError? + @State private var mfaRequired: MFARequired? + + private let email: String + private let coordinator: ReauthenticationCoordinator + private let content: (EmailReauthContentState) -> Content + + public var body: some View { + NavigationStack { + content(EmailReauthContentState( + password: $password, + verify: verify, + cancel: { coordinator.reauthCancelled() }, + email: email, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .mfaReauthResolution($mfaRequired, coordinator: coordinator) + } + .onChange(of: password) { errorMessage = nil } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } + + private var isValid: Bool { + !password.isEmpty + } + + private func verify() { + guard isValid, !isLoading else { return } + isLoading = true + errorMessage = nil + Task { + defer { isLoading = false } + do { + let credential = EmailAuthProvider.credential(withEmail: email, password: password) + try await authService.reauthenticate(with: credential) + coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift index 34a3fb497b1..a789009cd2d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EmailReauthView.swift @@ -12,119 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct EmailReauthView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - let email: String let coordinator: ReauthenticationCoordinator - - @State private var password = "" - @State private var isLoading = false - @State private var error: AlertError? - - private func verifyPassword() { - guard !password.isEmpty else { return } - - Task { @MainActor in - isLoading = true - do { - let credential = EmailAuthProvider.credential(withEmail: email, password: password) - try await authService.reauthenticate(with: credential) - coordinator.reauthCompleted() - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - } } extension EmailReauthView: View { public var body: some View { - NavigationStack { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "lock.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.blue) - - Text(authService.string.confirmPasswordTitle) - .authFont(.title) - .fontWeight(.bold) - - Text(authService.string.forSecurityEnterPasswordMessage) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding() - - VStack(spacing: 20) { - Text(authService.string.emailPrefix(email: email)) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - AuthTextField( - text: $password, - label: authService.string.passwordFieldLabel, - prompt: authService.string.passwordInputLabel, - contentType: .password, - isSecureTextField: true, - onSubmit: { _ in - verifyPassword() - }, - leading: { - Image(systemName: "lock") - } - ) - .submitLabel(.done) - .accessibilityIdentifier("email-reauth-password-field") - - Button(action: verifyPassword) { - if isLoading { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.confirmButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(password.isEmpty || isLoading) - .accessibilityIdentifier("confirm-password-button") - - Button(authService.string.cancelButtonLabel) { - coordinator.reauthCancelled() - } - } - .padding(.horizontal) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationBarTitleDisplayMode(.inline) + EmailReauthScreen(email: email, coordinator: coordinator) { + DefaultEmailReauthContent(state: $0) } - .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift new file mode 100644 index 00000000000..2b8535d3a55 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberScreen.swift @@ -0,0 +1,135 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the phone number screen, passed to ``EnterPhoneNumberScreen``'s +/// content. +public struct PhoneNumberContentState { + /// The national phone number, without the dial code. + public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. + public let selectedCountry: Binding + + /// Sends a verification code to the full phone number, then pushes + /// ``AuthView/enterVerificationCode(verificationID:fullPhoneNumber:)``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Whether ``phoneNumber`` is non-empty. + public let isValid: Bool + + /// Whether a code is being sent, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the number or country + /// changes or the next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(phoneNumber: Binding, + selectedCountry: Binding, + sendCode: @escaping @MainActor () -> Void, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.sendCode = sendCode + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The phone number screen, with its content drawn by you. +/// +/// The screen sends the code, navigates, and reports errors. Your content only draws the UI from +/// the ``PhoneNumberContentState`` it receives. Wrap ``DefaultEnterPhoneNumberContent`` to keep +/// the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .enterPhoneNumber: +/// EnterPhoneNumberScreen { state in +/// TextField("Phone number", text: state.phoneNumber) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterPhoneNumberScreen: View { + public init(@ViewBuilder content: @escaping (PhoneNumberContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var phoneNumber: String = "" + @State private var selectedCountry: CountryData = .default + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (PhoneNumberContentState) -> Content + + public var body: some View { + content(PhoneNumberContentState( + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + sendCode: sendCode, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } + } + + private var isValid: Bool { + !phoneNumber.isEmpty + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + authService.navigator.push(.enterVerificationCode( + verificationID: id, + fullPhoneNumber: fullPhoneNumber + )) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift index 54e41c1fa89..bfb76644125 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterPhoneNumberView.swift @@ -12,79 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI struct EnterPhoneNumberView: View { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var phoneNumber: String = "" - @State private var selectedCountry: CountryData = .default - var body: some View { - VStack(spacing: 16) { - Text(authService.string.enterPhoneNumberPlaceholder) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top) - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneFieldLabel, - prompt: authService.string.enterPhoneNumberPlaceholder, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !(authService.authenticationState == .authenticating) - ) - } - - Button(action: { - Task { - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let id = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) - authService.navigator.push(.enterVerificationCode( - verificationID: id, - fullPhoneNumber: fullPhoneNumber - )) - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || phoneNumber.isEmpty) - .padding(.top, 8) - - Spacer() - } - .navigationTitle(authService.string.phoneSignInTitle) - .padding(.horizontal) + EnterPhoneNumberScreen { DefaultEnterPhoneNumberContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift new file mode 100644 index 00000000000..5b681ccc8ed --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeScreen.swift @@ -0,0 +1,200 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import SwiftUI + +/// Seconds between sending a code and allowing a resend. +private let resendDelay: TimeInterval = 60 + +/// The state and actions of the verification code screen, passed to +/// ``EnterVerificationCodeScreen``'s content. +public struct VerificationCodeContentState { + /// The code the user typed. + public let verificationCode: Binding + + /// Signs in with the code, routing MFA and account conflicts to their handlers. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let verify: @MainActor () -> Void + + /// Sends a new code and clears ``verificationCode``. Does nothing while ``resendCountdown`` is + /// above zero or ``isLoading`` is `true`. + public let resendCode: @MainActor () -> Void + + /// Pops back to the phone number screen. + public let changeNumber: @MainActor () -> Void + + /// The number the code was sent to, including the dial code. + public let fullPhoneNumber: String + + /// Seconds left before ``resendCode`` is available; `0` when it is. + public let resendCountdown: Int + + /// Whether ``verificationCode`` has 6 digits. + public let isValid: Bool + + /// Whether a verify or resend is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed verify or resend. It clears when the code changes + /// or the next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(verificationCode: Binding, + verify: @escaping @MainActor () -> Void, + resendCode: @escaping @MainActor () -> Void, + changeNumber: @escaping @MainActor () -> Void, + fullPhoneNumber: String, + resendCountdown: Int, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.verify = verify + self.resendCode = resendCode + self.changeNumber = changeNumber + self.fullPhoneNumber = fullPhoneNumber + self.resendCountdown = resendCountdown + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The verification code screen, with its content drawn by you. +/// +/// The screen signs in, resends codes, runs the resend countdown, and routes MFA, account +/// conflicts and errors. It reads `verificationID` once; codes it resends replace it +/// internally. Your content only draws the UI from the ``VerificationCodeContentState`` it +/// receives. Wrap ``DefaultEnterVerificationCodeContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case let .enterVerificationCode(verificationID, fullPhoneNumber): +/// EnterVerificationCodeScreen( +/// verificationID: verificationID, +/// fullPhoneNumber: fullPhoneNumber +/// ) { state in +/// TextField("Code", text: state.verificationCode) +/// Button("Verify", action: state.verify) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct EnterVerificationCodeScreen: View { + public init(verificationID: String, + fullPhoneNumber: String, + @ViewBuilder content: @escaping (VerificationCodeContentState) -> Content) { + _verificationID = State(initialValue: verificationID) + self.fullPhoneNumber = fullPhoneNumber + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + @State private var verificationID: String + @State private var verificationCode: String = "" + @State private var resendAvailableAt = Date().addingTimeInterval(resendDelay) + @State private var resendCountdown = Int(resendDelay) + @State private var isWorking = false + @State private var errorMessage: String? + + private let fullPhoneNumber: String + private let content: (VerificationCodeContentState) -> Content + + public var body: some View { + content(VerificationCodeContentState( + verificationCode: $verificationCode, + verify: verify, + resendCode: resendCode, + changeNumber: { authService.navigator.pop() }, + fullPhoneNumber: fullPhoneNumber, + resendCountdown: resendCountdown, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: verificationCode) { errorMessage = nil } + .task(id: resendAvailableAt) { + while !Task.isCancelled { + resendCountdown = max(0, Int(resendAvailableAt.timeIntervalSinceNow.rounded(.up))) + guard resendCountdown > 0 else { return } + try? await Task.sleep(for: .seconds(1)) + } + } + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func verify() { + guard isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + switch try await authService.signIn(credentials: credential) { + case let .mfaRequired(mfaInfo): + mfaHandler?(mfaInfo) + case .signedIn: + authService.navigator.clear() + } + } catch { + if case let AuthServiceError.accountConflict(ctx) = error, + let onConflict = accountConflictHandler { + onConflict(ctx) + return + } + + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } + + private func resendCode() { + guard resendCountdown == 0, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: fullPhoneNumber) + verificationCode = "" + resendAvailableAt = Date().addingTimeInterval(resendDelay) + } catch { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift index 3a644e713c3..4924946f3c9 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/EnterVerificationCodeView.swift @@ -12,90 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor struct EnterVerificationCodeView: View { - @Environment(AuthService.self) private var authService - @Environment(\.accountConflictHandler) private var accountConflictHandler - @Environment(\.reportError) private var reportError - @State private var verificationCode: String = "" - let verificationID: String let fullPhoneNumber: String var body: some View { - @Bindable var authService = authService - VStack(spacing: 32) { - VStack(spacing: 16) { - VStack(spacing: 8) { - Text(authService.string.sentCodeMessage(phoneNumber: fullPhoneNumber)) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - authService.navigator.pop() - } label: { - Text(authService.string.changeNumberButtonLabel) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.bottom) - .frame(maxWidth: .infinity, alignment: .leading) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - - Button(action: { - Task { - do { - try await authService.signInWithPhoneNumber( - verificationID: verificationID, - verificationCode: verificationCode - ) - authService.navigator.clear() - } catch { - if case let AuthServiceError.accountConflict(ctx) = error, - let onConflict = accountConflictHandler { - onConflict(ctx) - return - } - - reportError?(error) - throw error - } - } - }) { - if authService.authenticationState == .authenticating { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyAndSignInButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(authService.authenticationState == .authenticating || verificationCode.count != 6) - } - - Spacer() - } - .navigationTitle(authService.string.enterVerificationCodeTitle) - .navigationBarTitleDisplayMode(.large) - .padding(.horizontal) + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { DefaultEnterVerificationCodeContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift new file mode 100644 index 00000000000..1b0f364a594 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentScreen.swift @@ -0,0 +1,441 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import FirebaseAuthUIComponents +import SwiftUI + +/// The state and actions of the MFA enrolment screen, passed to ``MFAEnrolmentScreen``'s content. +public struct MFAEnrollmentContentState { + /// A step of the enrolment flow. + /// + /// Later releases may add steps, so give a `switch` over it a `default` case. + public enum Step { + /// Choosing a factor, before ``MFAEnrollmentContentState/startEnrollment`` succeeds or once + /// the enrolment session has expired or finished. + case selectFactor + /// Entering the phone number and display name for an SMS factor. + case enterPhoneNumber + /// Entering the code sent to ``MFAEnrollmentContentState/sentPhoneNumber``. + case enterVerificationCode + /// Adding ``MFAEnrollmentContentState/totpSecret`` to an authenticator app, then entering + /// the display name and the code it generates. + case setUpTOTP + } + + /// The current step. + public let step: Step + + /// The factor ``startEnrollment`` sets up. When the screen appears, it sets it to the first of + /// ``allowedFactors`` if it is not one of them. + public let selectedFactorType: Binding + + /// The national phone number of an SMS factor, without the dial code. + public let phoneNumber: Binding + + /// The country whose dial code prefixes ``phoneNumber``. + public let selectedCountry: Binding + + /// The code sent by SMS. + public let verificationCode: Binding + + /// The code generated by the authenticator app. + public let totpCode: Binding + + /// The name the factor is listed under. + public let displayName: Binding + + /// Starts enrolling ``selectedFactorType``, moving to ``Step/enterPhoneNumber`` or + /// ``Step/setUpTOTP``. Does nothing unless ``step`` is ``Step/selectFactor``, ``isValid`` is + /// `true` and ``isLoading`` is `false`. + public let startEnrollment: @MainActor () -> Void + + /// Sends a code to the full phone number, moving to ``Step/enterVerificationCode``, or sends a + /// new one from that step. Does nothing unless ``step`` is ``Step/enterPhoneNumber`` or + /// ``Step/enterVerificationCode``, ``phoneNumber`` and ``displayName`` are non-empty, and + /// ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Enrols the factor with the SMS or authenticator code, reauthenticating first when Firebase + /// requires it, then clears navigation. Does nothing unless ``step`` is + /// ``Step/enterVerificationCode`` or ``Step/setUpTOTP``, ``isValid`` is `true` and + /// ``isLoading`` is `false`. + public let enroll: @MainActor () -> Void + + /// Copies ``totpSecret`` to the pasteboard and sets ``didCopySecret`` briefly. + public let copySecret: @MainActor () -> Void + + /// Whether MFA is enabled in the ``AuthConfiguration``. + public let isMFAEnabled: Bool + + /// The factors the ``AuthConfiguration`` allows, SMS first. + public let allowedFactors: [SecondFactorType] + + /// The number the code was sent to, including the dial code, from + /// ``Step/enterVerificationCode``. + public let sentPhoneNumber: String? + + /// The shared secret to enter in an authenticator app, from ``Step/setUpTOTP``. + public let totpSecret: String? + + /// The `otpauth://` URL to show as a QR code or open in an authenticator app, from + /// ``Step/setUpTOTP``. + public let totpQRCodeURL: URL? + + /// Whether ``copySecret`` just copied the secret. + public let didCopySecret: Bool + + /// Whether the current step's input is complete: MFA is enabled and ``selectedFactorType`` is + /// one of ``allowedFactors`` in ``Step/selectFactor``, ``phoneNumber`` and ``displayName`` are + /// non-empty in ``Step/enterPhoneNumber``, and ``displayName`` plus the step's code are + /// non-empty in ``Step/enterVerificationCode`` and ``Step/setUpTOTP``. + public let isValid: Bool + + /// Whether an enrolment action is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed action. It clears when any input changes or the + /// next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(step: Step, + selectedFactorType: Binding, + phoneNumber: Binding, + selectedCountry: Binding, + verificationCode: Binding, + totpCode: Binding, + displayName: Binding, + startEnrollment: @escaping @MainActor () -> Void, + sendCode: @escaping @MainActor () -> Void, + enroll: @escaping @MainActor () -> Void, + copySecret: @escaping @MainActor () -> Void, + isMFAEnabled: Bool, + allowedFactors: [SecondFactorType], + sentPhoneNumber: String?, + totpSecret: String?, + totpQRCodeURL: URL?, + didCopySecret: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.step = step + self.selectedFactorType = selectedFactorType + self.phoneNumber = phoneNumber + self.selectedCountry = selectedCountry + self.verificationCode = verificationCode + self.totpCode = totpCode + self.displayName = displayName + self.startEnrollment = startEnrollment + self.sendCode = sendCode + self.enroll = enroll + self.copySecret = copySecret + self.isMFAEnabled = isMFAEnabled + self.allowedFactors = allowedFactors + self.sentPhoneNumber = sentPhoneNumber + self.totpSecret = totpSecret + self.totpQRCodeURL = totpQRCodeURL + self.didCopySecret = didCopySecret + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The MFA enrolment screen, with its content drawn by you. +/// +/// The screen starts the enrolment, sends SMS codes, enrols the factor, presents +/// reauthentication when Firebase requires it, and reports errors. Your content only draws the +/// UI for the ``MFAEnrollmentContentState/step`` it receives. Wrap ``DefaultMFAEnrolmentContent`` +/// to keep the stock markup. The navigation title is part of that markup, so set your own on +/// custom content. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .mfaEnrollment: +/// MFAEnrolmentScreen { state in +/// switch state.step { +/// case .enterPhoneNumber: +/// TextField("Phone number", text: state.phoneNumber) +/// TextField("Device name", text: state.displayName) +/// Button("Send code", action: state.sendCode) +/// .disabled(!state.isValid || state.isLoading) +/// default: +/// DefaultMFAEnrolmentContent(state: state) +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct MFAEnrolmentScreen: View { + public init(@ViewBuilder content: @escaping (MFAEnrollmentContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var selectedFactorType: SecondFactorType = .sms + @State private var phoneNumber = "" + @State private var selectedCountry: CountryData = .default + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var displayName = "" + @State private var currentSession: EnrollmentSession? + @State private var didCopySecret = false + @State private var copyFeedback: Task? + @State private var isWorking = false + @State private var errorMessage: String? + @State private var reauthCoordinator = ReauthenticationCoordinator() + + private let content: (MFAEnrollmentContentState) -> Content + + public var body: some View { + content(MFAEnrollmentContentState( + step: step, + selectedFactorType: $selectedFactorType, + phoneNumber: $phoneNumber, + selectedCountry: $selectedCountry, + verificationCode: $verificationCode, + totpCode: $totpCode, + displayName: $displayName, + startEnrollment: startEnrollment, + sendCode: sendCode, + enroll: enroll, + copySecret: copySecret, + isMFAEnabled: authService.configuration.mfaEnabled, + allowedFactors: allowedFactors, + sentPhoneNumber: currentSession?.phoneNumber, + totpSecret: currentSession?.totpInfo?.sharedSecretKey, + totpQRCodeURL: currentSession?.totpInfo?.qrCodeURL, + didCopySecret: didCopySecret, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .withReauthentication(coordinator: reauthCoordinator) + .onAppear { + if !allowedFactors.contains(selectedFactorType), + let firstAllowed = allowedFactors.first { + selectedFactorType = firstAllowed + } + } + .onChange(of: selectedFactorType) { errorMessage = nil } + .onChange(of: phoneNumber) { errorMessage = nil } + .onChange(of: selectedCountry) { errorMessage = nil } + .onChange(of: verificationCode) { errorMessage = nil } + .onChange(of: totpCode) { errorMessage = nil } + .onChange(of: displayName) { errorMessage = nil } + } + + private var allowedFactors: [SecondFactorType] { + MFAEnrollmentContentState.sortedFactors(authService.configuration.allowedSecondFactors) + } + + private var step: MFAEnrollmentContentState.Step { + guard let session = currentSession else { return .selectFactor } + return MFAEnrollmentContentState.Step( + type: session.type, + status: session.status, + isExpired: session.isExpired + ) + } + + private var isValid: Bool { + step.isValid( + isMFAEnabled: authService.configuration.mfaEnabled, + allowedFactors: allowedFactors, + selectedFactorType: selectedFactorType, + phoneNumber: phoneNumber, + verificationCode: verificationCode, + totpCode: totpCode, + displayName: displayName + ) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func startEnrollment() { + guard step.canStartEnrollment(isValid: isValid, isLoading: isLoading) else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + currentSession = try await authService.startMfaEnrollment( + type: selectedFactorType, + accountName: authService.currentUser?.email, + issuer: authService.configuration.mfaIssuer + ) + } catch { + fail(error) + } + } + } + + private func sendCode() { + guard let session = currentSession, + step.canSendCode( + phoneNumber: phoneNumber, + displayName: displayName, + isLoading: isLoading + ) else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let fullPhoneNumber = selectedCountry.dialCode + phoneNumber + let verificationId = try await authService.sendSmsVerificationForEnrollment( + session: session, + phoneNumber: fullPhoneNumber + ) + currentSession = EnrollmentSession( + id: session.id, + type: session.type, + session: session.session, + totpInfo: session.totpInfo, + phoneNumber: fullPhoneNumber, + verificationId: verificationId, + status: .verificationSent, + createdAt: session.createdAt, + expiresAt: session.expiresAt + ) + } catch { + fail(error) + } + } + } + + private func enroll() { + guard let session = currentSession, + step.canEnroll(isValid: isValid, isLoading: isLoading) else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let code = session.type == .sms ? verificationCode : totpCode + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.completeEnrollment( + session: session, + verificationId: session.verificationId, + verificationCode: code, + displayName: displayName + ) + } + resetForm() + authService.navigator.clear() + } catch { + fail(error) + } + } + } + + private func copySecret() { + guard let secret = currentSession?.totpInfo?.sharedSecretKey else { return } + UIPasteboard.general.string = secret + didCopySecret = true + copyFeedback?.cancel() + copyFeedback = Task { + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled else { return } + didCopySecret = false + } + } + + private func fail(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + reportError?(error) + } + + private func resetForm() { + currentSession = nil + phoneNumber = "" + selectedCountry = .default + verificationCode = "" + totpCode = "" + displayName = "" + } +} + +extension MFAEnrollmentContentState { + /// The factors in display order, SMS first. + static func sortedFactors(_ factors: Set) -> [SecondFactorType] { + factors.sorted { lhs, rhs in lhs == .sms && rhs == .totp } + } +} + +extension MFAEnrollmentContentState.Step { + /// The step for a started enrolment session. Sessions that have expired or finished go back to + /// ``selectFactor`` so the user can start again. + init(type: SecondFactorType, status: EnrollmentSession.EnrollmentStatus, isExpired: Bool) { + switch (type, status) { + case _ where isExpired, (_, .completed), (_, .failed), (_, .expired): + self = .selectFactor + case (.sms, .initiated): + self = .enterPhoneNumber + case (.sms, .verificationSent), (.sms, .verificationPending): + self = .enterVerificationCode + case (.totp, _): + self = .setUpTOTP + } + } + + /// Whether the input this step needs is complete. + func isValid(isMFAEnabled: Bool, + allowedFactors: [SecondFactorType], + selectedFactorType: SecondFactorType, + phoneNumber: String, + verificationCode: String, + totpCode: String, + displayName: String) -> Bool { + switch self { + case .selectFactor: + isMFAEnabled && allowedFactors.contains(selectedFactorType) + case .enterPhoneNumber: + !phoneNumber.isEmpty && !displayName.isEmpty + case .enterVerificationCode: + !verificationCode.isEmpty && !displayName.isEmpty + case .setUpTOTP: + !totpCode.isEmpty && !displayName.isEmpty + } + } + + /// Whether ``MFAEnrollmentContentState/startEnrollment`` runs from this step. + func canStartEnrollment(isValid: Bool, isLoading: Bool) -> Bool { + self == .selectFactor && isValid && !isLoading + } + + /// Whether ``MFAEnrollmentContentState/sendCode`` runs from this step. + func canSendCode(phoneNumber: String, displayName: String, isLoading: Bool) -> Bool { + (self == .enterPhoneNumber || self == .enterVerificationCode) + && !phoneNumber.isEmpty && !displayName.isEmpty && !isLoading + } + + /// Whether ``MFAEnrollmentContentState/enroll`` runs from this step. + func canEnroll(isValid: Bool, isLoading: Bool) -> Bool { + (self == .enterVerificationCode || self == .setUpTOTP) && isValid && !isLoading + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift index 7405ab1db16..cf61d3885c3 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAEnrolmentView.swift @@ -12,638 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI -private enum FocusableField: Hashable { - case phoneNumber - case verificationCode - case totpCode -} - @MainActor public struct MFAEnrolmentView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - - @State private var selectedFactorType: SecondFactorType = .sms - @State private var phoneNumber = "" - @State private var selectedCountry: CountryData = .default - @State private var verificationCode = "" - @State private var totpCode = "" - @State private var currentSession: EnrollmentSession? - @State private var isLoading = false - @State private var displayName = "" - @State private var showCopiedFeedback = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - - @FocusState private var focus: FocusableField? - public init() {} - - private var allowedFactorTypes: [SecondFactorType] { - return Array(authService.configuration.allowedSecondFactors).sorted { lhs, rhs in - // Sort SMS first, then TOTP - switch (lhs, rhs) { - case (.sms, .totp): return true - case (.totp, .sms): return false - default: return false - } - } - } - - private var canStartEnrollment: Bool { - !isLoading && currentSession == nil && authService.configuration.mfaEnabled - } - - private var canSendSMSVerification: Bool { - currentSession?.type == .sms && - currentSession?.status == .initiated && - !phoneNumber.isEmpty && - !displayName.isEmpty && - !isLoading - } - - private var canCompleteEnrollment: Bool { - guard let session = currentSession, !isLoading else { return false } - - switch session.type { - case .sms: - return session.status == .verificationSent && !verificationCode.isEmpty && !displayName - .isEmpty - case .totp: - return session.status == .initiated && !totpCode.isEmpty && !displayName.isEmpty - } - } - - private func startEnrollment() { - Task { - isLoading = true - defer { isLoading = false } - - do { - let session = try await authService.startMfaEnrollment( - type: selectedFactorType, - accountName: authService.currentUser?.email, - issuer: authService.configuration.mfaIssuer - ) - currentSession = session - } catch { - reportError?(error) - } - } - } - - private func sendSMSVerification() { - guard let session = currentSession else { return } - - Task { - isLoading = true - defer { isLoading = false } - - do { - let fullPhoneNumber = selectedCountry.dialCode + phoneNumber - let verificationId = try await authService.sendSmsVerificationForEnrollment( - session: session, - phoneNumber: fullPhoneNumber - ) - // Update session status - currentSession = EnrollmentSession( - id: session.id, - type: session.type, - session: session.session, - totpInfo: session.totpInfo, - phoneNumber: fullPhoneNumber, - verificationId: verificationId, - status: .verificationSent, - createdAt: session.createdAt, - expiresAt: session.expiresAt - ) - } catch { - reportError?(error) - } - } - } - - private func completeEnrollment() { - guard let session = currentSession else { return } - - Task { - isLoading = true - defer { isLoading = false } - - do { - let code = session.type == .sms ? verificationCode : totpCode - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - try await authService.completeEnrollment( - session: session, - verificationId: session.verificationId, - verificationCode: code, - displayName: displayName - ) - } - - // Reset form state on success - resetForm() - - authService.navigator.clear() - } catch { - reportError?(error) - } - } - } - - private func resetForm() { - currentSession = nil - phoneNumber = "" - selectedCountry = .default - verificationCode = "" - totpCode = "" - displayName = "" - focus = nil - } - - private func copyToClipboard(_ text: String) { - UIPasteboard.general.string = text - - // Show feedback - showCopiedFeedback = true - - // Quickly show it has been copied to the clipboard - Task { - try? await Task.sleep(nanoseconds: 500_000_000) - showCopiedFeedback = false - } - } - - private func generateQRCode(from string: String) -> UIImage? { - let data = Data(string.utf8) - - guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } - filter.setValue(data, forKey: "inputMessage") - filter.setValue("H", forKey: "inputCorrectionLevel") - - guard let ciImage = filter.outputImage else { return nil } - - // Scale up the QR code for better quality - let transform = CGAffineTransform(scaleX: 10, y: 10) - let scaledImage = ciImage.transformed(by: transform) - - let context = CIContext() - guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { - return nil - } - - return UIImage(cgImage: cgImage) - } } extension MFAEnrolmentView: View { public var body: some View { - VStack(spacing: 24) { - // Header (only shown when no session is active) - if currentSession == nil { - VStack(spacing: 8) { - Text("Set Up Two-Factor Authentication") - .authFont(.largeTitle) - .fontWeight(.bold) - .multilineTextAlignment(.center) - - Text("Add an extra layer of security to your account") - .authFont(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } - - // Factor Type Selection (only if no session started) - if currentSession == nil { - if !authService.configuration.mfaEnabled { - VStack(spacing: 12) { - Image(systemName: "lock.slash") - .font(.system(size: 40)) - .foregroundColor(.orange) - - Text("Multi-Factor Authentication Disabled") - .authFont(.title2) - .fontWeight(.semibold) - - Text( - "MFA is not enabled in the current configuration. Please contact your administrator." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .accessibilityIdentifier("mfa-disabled-message") - } else if allowedFactorTypes.isEmpty { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 40)) - .foregroundColor(.orange) - - Text("No Authentication Methods Available") - .authFont(.title2) - .fontWeight(.semibold) - - Text("No MFA methods are configured as allowed. Please contact your administrator.") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .accessibilityIdentifier("no-factors-message") - } else { - VStack(alignment: .leading, spacing: 12) { - Text("Choose Authentication Method") - .authFont(.headline) - - Picker("Authentication Method", selection: $selectedFactorType) { - ForEach(allowedFactorTypes, id: \.self) { factorType in - switch factorType { - case .sms: - Image(systemName: "message").tag(SecondFactorType.sms) - case .totp: - Image(systemName: "qrcode").tag(SecondFactorType.totp) - } - } - } - .pickerStyle(.segmented) - .accessibilityIdentifier("factor-type-picker") - } - } - } - - // Content based on current state - if let session = currentSession { - enrollmentContent(for: session) - } else { - initialContent - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .navigationTitle("Two-Factor Authentication") - .onAppear { - // Initialize selected factor type to first allowed type - if !allowedFactorTypes.contains(selectedFactorType), - let firstAllowed = allowedFactorTypes.first { - selectedFactorType = firstAllowed - } - } - } - - @ViewBuilder - private var initialContent: some View { - VStack(spacing: 24) { - // Description based on selected type - if selectedFactorType == .sms { - VStack(spacing: 8) { - Image(systemName: "message.circle") - .font(.system(size: 40)) - .foregroundColor(.blue) - - Text("SMS Authentication") - .authFont(.title2) - .fontWeight(.semibold) - - Text("We'll send a verification code to your phone number each time you sign in.") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } else { - VStack(spacing: 8) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) - - Text("Authenticator App") - .authFont(.title2) - .fontWeight(.semibold) - - Text( - "Use an authenticator app like Google Authenticator or Authy to generate verification codes." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } - - Button { - startEnrollment() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Get Started") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canStartEnrollment) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("start-enrollment-button") - } - } - - @ViewBuilder - private func enrollmentContent(for session: EnrollmentSession) -> some View { - switch session.type { - case .sms: - smsEnrollmentContent(session: session) - case .totp: - totpEnrollmentContent(session: session) - } - } - - @ViewBuilder - private func smsEnrollmentContent(session: EnrollmentSession) -> some View { - VStack(spacing: 24) { - // SMS enrollment steps - if session.status == .initiated { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "phone") - .font(.system(size: 48)) - .foregroundColor(.blue) - - Text("Enter Your Phone Number") - .authFont(.title2) - .fontWeight(.semibold) - - Text("We'll send a verification code to this number") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - AuthTextField( - text: $phoneNumber, - label: authService.string.phoneNumberFieldLabel, - prompt: authService.string.enterPhoneNumberPrompt, - keyboardType: .phonePad, - contentType: .telephoneNumber, - validations: [ - FormValidators.phoneNumber, - ], - maintainsValidationMessage: true, - onChange: { _ in } - ) { - CountrySelector( - selectedCountry: $selectedCountry, - enabled: !isLoading - ) - } - .focused($focus, equals: .phoneNumber) - .accessibilityIdentifier("phone-number-field") - - AuthTextField( - text: $displayName, - label: authService.string.displayNameFieldLabel, - prompt: authService.string.enterDisplayNameForDevicePrompt, - validations: [ - FormValidators.notEmpty(label: "Display name"), - ], - maintainsValidationMessage: true, - leading: { - Image(systemName: "person") - } - ) - .accessibilityIdentifier("display-name-field") - - Button { - sendSMSVerification() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Send Code") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canSendSMSVerification) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("send-sms-button") - } - } else if session.status == .verificationSent { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "checkmark.message") - .font(.system(size: 48)) - .foregroundColor(.green) - - Text("Enter Verification Code") - .authFont(.title2) - .fontWeight(.semibold) - - Text("We sent a code to \(session.phoneNumber ?? "your phone")") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("verification-code-field") - - Button { - completeEnrollment() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Setup") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canCompleteEnrollment) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("complete-enrollment-button") - - Button { - sendSMSVerification() - } label: { - Text("Resend Code") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("resend-code-button") - } - } - } - } - - @ViewBuilder - private func totpEnrollmentContent(session: EnrollmentSession) -> some View { - VStack(spacing: 16) { - if let totpInfo = session.totpInfo { - VStack(spacing: 16) { - VStack(spacing: 6) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) - - Text("Scan QR Code") - .authFont(.title2) - .fontWeight(.semibold) - - Text("Scan with your authenticator app or tap to open directly") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineLimit(nil) - .padding(.horizontal) - } - - // QR Code generated from the otpauth:// URI - if let qrURL = totpInfo.qrCodeURL, - let qrImage = generateQRCode(from: qrURL.absoluteString) { - Button(action: { - UIApplication.shared.open(qrURL) - }) { - VStack(spacing: 8) { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 180, height: 180) - .accessibilityIdentifier("qr-code-image") - - HStack(spacing: 6) { - Image(systemName: "arrow.up.forward.app.fill") - .authFont(.caption) - Text("Tap to open in authenticator app") - .authFont(.caption) - .fontWeight(.medium) - } - .foregroundColor(.blue) - } - } - .buttonStyle(.plain) - .accessibilityIdentifier("open-authenticator-button") - } else { - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.3)) - .frame(width: 180, height: 180) - .overlay( - VStack { - Image(systemName: "exclamationmark.triangle") - .authFont(.title) - .foregroundColor(.orange) - Text("Unable to generate QR Code") - .authFont(.caption) - } - ) - } - - VStack(spacing: 6) { - Text("Manual Entry Key:") - .authFont(.headline) - - Button(action: { - copyToClipboard(totpInfo.sharedSecretKey) - }) { - HStack { - Text(totpInfo.sharedSecretKey) - .font(.system(.body, design: .monospaced)) - .lineLimit(1) - .minimumScaleFactor(0.5) - - Spacer() - - Image(systemName: showCopiedFeedback ? "checkmark" : "doc.on.doc") - .foregroundColor(showCopiedFeedback ? .green : .blue) - } - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - .buttonStyle(.plain) - .accessibilityIdentifier("totp-secret-key") - - if showCopiedFeedback { - Text("Copied to clipboard!") - .authFont(.caption) - .foregroundColor(.green) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.2), value: showCopiedFeedback) - - AuthTextField( - text: $displayName, - label: authService.string.displayNameFieldLabel, - prompt: authService.string.enterDisplayNameForAuthenticatorPrompt, - validations: [ - FormValidators.notEmpty(label: "Display name"), - ], - maintainsValidationMessage: true, - leading: { - Image(systemName: "person") - } - ) - .accessibilityIdentifier("display-name-field") - - VerificationCodeInputField( - code: $totpCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("totp-code-field") - - Button { - completeEnrollment() - } label: { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Setup") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canCompleteEnrollment) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("complete-enrollment-button") - } - } - } + MFAEnrolmentScreen { DefaultMFAEnrolmentContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift new file mode 100644 index 00000000000..520d76f9ebf --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementScreen.swift @@ -0,0 +1,152 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the MFA management screen, passed to ``MFAManagementScreen``'s +/// content. +public struct MFAManagementContentState { + /// Removes the enrolled factor with this `uid`, then updates ``enrolledFactors``. Asks the user + /// to reauthenticate first when Firebase requires it. Does nothing while ``isLoading`` is + /// `true`. + public let unenroll: @MainActor (_ factorUid: String) -> Void + + /// Pushes ``AuthView/mfaEnrollment``. + public let goToEnrollment: @MainActor () -> Void + + /// Re-reads ``enrolledFactors`` from the signed-in user and clears ``errorMessage``. + public let refresh: @MainActor () -> Void + + /// The current user's enrolled second factors. + public let enrolledFactors: [MultiFactorInfo] + + /// Whether an unenroll, including its reauthentication, is in flight, or the ``AuthService`` + /// is signing in. + public let isLoading: Bool + + /// The localized message of the last failed unenroll; cancelling reauthentication sets none. It + /// clears on ``refresh`` or when the next attempt starts. The sheet's error alert still shows + /// for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(unenroll: @escaping @MainActor (_ factorUid: String) -> Void, + goToEnrollment: @escaping @MainActor () -> Void, + refresh: @escaping @MainActor () -> Void, + enrolledFactors: [MultiFactorInfo], + isLoading: Bool, + errorMessage: String?) { + self.unenroll = unenroll + self.goToEnrollment = goToEnrollment + self.refresh = refresh + self.enrolledFactors = enrolledFactors + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The MFA management screen, with its content drawn by you. +/// +/// The screen loads the enrolled factors, unenrolls them, presents reauthentication when +/// Firebase requires it, navigates, and reports errors. Your content only draws the UI from the +/// ``MFAManagementContentState`` it receives. Wrap ``DefaultMFAManagementContent`` to keep the +/// stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .mfaManagement: +/// MFAManagementScreen { state in +/// ForEach(state.enrolledFactors) { factor in +/// Button("Remove \(factor.displayName ?? factor.uid)") { +/// state.unenroll(factor.uid) +/// } +/// .disabled(state.isLoading) +/// } +/// Button("Add method", action: state.goToEnrollment) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct MFAManagementScreen: View { + public init(@ViewBuilder content: @escaping (MFAManagementContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var enrolledFactors: [MultiFactorInfo] = [] + @State private var isWorking = false + @State private var errorMessage: String? + @State private var reauthCoordinator = ReauthenticationCoordinator() + + private let content: (MFAManagementContentState) -> Content + + public var body: some View { + // A single container, so the reauthentication sheets attach once even when the content + // returns several views, or none. + VStack(spacing: 0) { + content(MFAManagementContentState( + unenroll: unenroll, + goToEnrollment: { authService.navigator.push(.mfaEnrollment) }, + refresh: refresh, + enrolledFactors: enrolledFactors, + isLoading: isLoading, + errorMessage: errorMessage + )) + } + .withReauthentication(coordinator: reauthCoordinator) + .onAppear(perform: refresh) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func refresh() { + errorMessage = nil + guard let user = authService.currentUser else { return } + enrolledFactors = user.multiFactor.enrolledFactors + } + + private func unenroll(_ factorUid: String) { + guard !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + enrolledFactors = try await authService.unenrollMFA(factorUid) + } + } catch { + if case AuthServiceError.signInCancelled = error { + // The user backed out of reauthentication; nothing failed. + } else { + errorMessage = authService.string.localizedErrorMessage(for: error) + } + reportError?(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift index b8a982ee7ff..183ea7e8cf7 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAManagementView.swift @@ -22,190 +22,15 @@ extension MultiFactorInfo: @retroactive Identifiable { @MainActor public struct MFAManagementView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - - @State private var enrolledFactors: [MultiFactorInfo] = [] - @State private var isLoading = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - public init() {} - - private func loadEnrolledFactors() { - guard let user = authService.currentUser else { return } - enrolledFactors = user.multiFactor.enrolledFactors - } - - private func unenrollFactor(_ factorUid: String) { - Task { - isLoading = true - - do { - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - let freshFactors = try await authService.unenrollMFA(factorUid) - enrolledFactors = freshFactors - } - isLoading = false - } catch { - reportError?(error) - isLoading = false - } - } - } - - private func navigateToEnrollment() { - authService.navigator.push(.mfaEnrollment) - } } extension MFAManagementView: View { public var body: some View { - VStack(spacing: 20) { - // Title section - VStack { - Text("Two-Factor Authentication") - .authFont(.largeTitle) - .fontWeight(.bold) - .multilineTextAlignment(.center) - - Text("Manage your authentication methods") - .authFont(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - - if enrolledFactors.isEmpty { - // No factors enrolled - VStack(spacing: 16) { - Image(systemName: "shield.slash") - .font(.system(size: 48)) - .foregroundColor(.orange) - - Text("No Authentication Methods") - .authFont(.title2) - .fontWeight(.semibold) - - Text( - "Set up two-factor authentication to add an extra layer of security to your account." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button { - navigateToEnrollment() - } label: { - Text("Set Up Two-Factor Authentication") - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("setup-mfa-button") - } - } else { - // Show enrolled factors - VStack(alignment: .leading, spacing: 16) { - Text("Enrolled Methods") - .authFont(.headline) - .padding(.horizontal) - - ForEach(enrolledFactors) { factor in - factorRow(factor: factor) - } - - Divider() - .padding(.horizontal) - - Button("Add Another Method") { - navigateToEnrollment() - } - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - .accessibilityIdentifier("add-mfa-method-button") - } - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .onAppear { - loadEnrolledFactors() - } - } - - @ViewBuilder - private func factorRow(factor: MultiFactorInfo) -> some View { - HStack { - // Factor type icon - Group { - if factor.factorID == PhoneMultiFactorID { - Image(systemName: "message") - .foregroundColor(.blue) - } else { - Image(systemName: "qrcode") - .foregroundColor(.green) - } - } - .authFont(.title2) - - VStack(alignment: .leading, spacing: 4) { - Text(factor.displayName ?? authService.string.unnamedMethodLabel) - .authFont(.headline) - - if factor.factorID == PhoneMultiFactorID { - let phoneInfo = factor as! PhoneMultiFactorInfo - Text("SMS: \(phoneInfo.phoneNumber)") - .authFont(.caption) - .foregroundColor(.secondary) - } else { - Text("Authenticator App") - .authFont(.caption) - .foregroundColor(.secondary) - } - - Text("Enrolled: \(DateFormatter.shortDate.string(from: factor.enrollmentDate))") - .authFont(.caption2) - .foregroundColor(.secondary) - } - - Spacer() - - Button("Remove") { - unenrollFactor(factor.uid) - } - .buttonStyle(.bordered) - .foregroundColor(.red) - .disabled(isLoading) - .accessibilityIdentifier("remove-factor-\(factor.uid)") - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) + MFAManagementScreen { DefaultMFAManagementContent(state: $0) } } } -// MARK: - Date Formatter Extension - -private extension DateFormatter { - static let shortDate: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return formatter - }() -} - #Preview { FirebaseOptions.dummyConfigurationForPreview() return NavigationStack { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift new file mode 100644 index 00000000000..ea2c48217c4 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionScreen.swift @@ -0,0 +1,260 @@ +// Copyright 2025 Google LLC +// +// 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. + +import SwiftUI + +/// The state and actions of the MFA resolution screen, passed to ``MFAResolutionScreen``'s +/// content. +public struct MFAResolutionContentState { + /// The index in ``hints`` of the factor the user resolves with. Setting it, even to the + /// current index, clears ``verificationCode``, ``totpCode`` and ``isCodeSent``. + public let selectedHintIndex: Binding + + /// The SMS code the user typed, for a phone hint. + public let verificationCode: Binding + + /// The authenticator app code the user typed, for a TOTP hint. + public let totpCode: Binding + + /// Sends an SMS code to the selected phone hint and sets ``isCodeSent``. Does nothing unless + /// the selected hint is a phone hint and ``isLoading`` is `false`. + public let sendCode: @MainActor () -> Void + + /// Completes sign-in with the code for the selected hint, then clears navigation. Does nothing + /// unless ``isValid`` is `true` and ``isLoading`` is `false`. + public let resolve: @MainActor () -> Void + + /// Abandons sign-in and clears navigation. + public let cancel: @MainActor () -> Void + + /// The second factors the user can resolve with. + public let hints: [MFAHint] + + /// Whether an SMS code was sent for the selected phone hint. + public let isCodeSent: Bool + + /// Whether the code for the selected hint is non-empty. + public let isValid: Bool + + /// Whether a send or resolve is in flight, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed send or resolve. It clears when a code or the + /// selected hint changes or the next attempt starts. The sheet's error alert still shows for + /// the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(selectedHintIndex: Binding, + verificationCode: Binding, + totpCode: Binding, + sendCode: @escaping @MainActor () -> Void, + resolve: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + hints: [MFAHint], + isCodeSent: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.selectedHintIndex = selectedHintIndex + self.verificationCode = verificationCode + self.totpCode = totpCode + self.sendCode = sendCode + self.resolve = resolve + self.cancel = cancel + self.hints = hints + self.isCodeSent = isCodeSent + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The MFA resolution screen, with its content drawn by you. +/// +/// The screen sends SMS codes, completes sign-in with the second factor, navigates, and reports +/// errors. Your content only draws the UI from the ``MFAResolutionContentState`` it receives. +/// Wrap ``DefaultMFAResolutionContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// 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) +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct MFAResolutionScreen: View { + public init(mfaRequired: MFARequired, + @ViewBuilder content: @escaping (MFAResolutionContentState) -> Content) { + self.init(mfaRequired: mfaRequired, onResolved: nil, onCancel: nil, content: content) + } + + /// Used outside the auth navigation stack (e.g. reauthentication), where completion and + /// cancellation are handled by the caller instead of clearing the navigator + init(mfaRequired: MFARequired, + onResolved: (() -> Void)?, + onCancel: (() -> Void)?, + @ViewBuilder content: @escaping (MFAResolutionContentState) -> Content) { + self.mfaRequired = mfaRequired + self.onResolved = onResolved + self.onCancel = onCancel + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var selectedHintIndex = 0 + @State private var verificationCode = "" + @State private var totpCode = "" + @State private var verificationId: String? + @State private var isWorking = false + @State private var errorMessage: String? + @State private var alertError: AlertError? + + private let mfaRequired: MFARequired + private let onResolved: (() -> Void)? + private let onCancel: (() -> Void)? + private let content: (MFAResolutionContentState) -> Content + + public var body: some View { + // A single container, so the modifiers attach once even when the content returns several + // views. + VStack(spacing: 0) { + content(MFAResolutionContentState( + selectedHintIndex: Binding(get: { selectedHintIndex }, set: selectHint), + verificationCode: $verificationCode, + totpCode: $totpCode, + sendCode: sendCode, + resolve: resolve, + cancel: cancel, + hints: mfaRequired.hints, + isCodeSent: verificationId != nil, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + } + .onChange(of: verificationCode) { errorMessage = nil } + .onChange(of: totpCode) { errorMessage = nil } + // Without a parent reporter (e.g. inside a reauth sheet), show failures here + .errorAlert(error: $alertError, okButtonLabel: authService.string.okButtonLabel) + } + + private func cancel() { + if let onCancel { + onCancel() + } else { + authService.navigator.clear() + } + } + + private func report(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + alertError = AlertError( + title: "Error", + message: errorMessage ?? error.localizedDescription, + underlyingError: error + ) + } + } + + private func selectHint(_ index: Int) { + selectedHintIndex = index + verificationCode = "" + totpCode = "" + verificationId = nil + errorMessage = nil + } + + private var selectedHint: MFAHint? { + guard mfaRequired.hints.indices.contains(selectedHintIndex) else { return nil } + return mfaRequired.hints[selectedHintIndex] + } + + private var isValid: Bool { + switch selectedHint { + case .phone: + return !verificationCode.isEmpty + case .totp: + return !totpCode.isEmpty + case .none: + return false + } + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendCode() { + guard case .phone = selectedHint, !isLoading else { return } + isWorking = true + errorMessage = nil + let hintIndex = selectedHintIndex + Task { + defer { isWorking = false } + do { + let id = try await authService.resolveSmsChallenge(hintIndex: hintIndex) + // Drop a code sent for a hint the user has since switched away from. + guard selectedHintIndex == hintIndex else { return } + verificationId = id + } catch { + report(error) + } + } + } + + private func resolve() { + guard let selectedHint, isValid, !isLoading else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + let code: String + switch selectedHint { + case .phone: + code = verificationCode + case .totp: + code = totpCode + } + try await authService.resolveSignIn( + code: code, + hintIndex: selectedHintIndex, + verificationId: verificationId + ) + if let onResolved { + onResolved() + } else { + authService.navigator.clear() + } + } catch { + report(error) + } + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift index e3dd9a28c2e..3f0de0ba7ae 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/MFAResolutionView.swift @@ -12,345 +12,35 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct MFAResolutionView { let mfaRequired: MFARequired - - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - - @State private var verificationCode = "" - @State private var totpCode = "" - @State private var isLoading = false - @State private var selectedHintIndex = 0 - @State private var verificationId: String? + private let onResolved: (() -> Void)? + private let onCancel: (() -> Void)? public init(mfaRequired: MFARequired) { - self.mfaRequired = mfaRequired - } - - private var selectedHint: MFAHint? { - guard selectedHintIndex < mfaRequired.hints.count else { - return nil - } - return mfaRequired.hints[selectedHintIndex] - } - - private var canCompleteResolution: Bool { - guard !isLoading else { return false } - - switch selectedHint { - case .phone: - return !verificationCode.isEmpty - case .totp: - return !totpCode.isEmpty - case .none: - return false - } + self.init(mfaRequired: mfaRequired, onResolved: nil, onCancel: nil) } - private func startSMSChallenge() { - guard selectedHintIndex < mfaRequired.hints.count else { return } - - Task { - isLoading = true - - do { - let verificationId = try await authService.resolveSmsChallenge(hintIndex: selectedHintIndex) - self.verificationId = verificationId - isLoading = false - } catch { - reportError?(error) - isLoading = false - } - } - } - - private func completeResolution() { - Task { - isLoading = true - - do { - let code = selectedHint?.isPhoneHint == true ? verificationCode : totpCode - try await authService.resolveSignIn( - code: code, - hintIndex: selectedHintIndex, - verificationId: verificationId - ) - // On success, the AuthService will update the authentication state - // and we should navigate back to the main app - authService.navigator.clear() - isLoading = false - } catch { - reportError?(error) - isLoading = false - } - } - } - - private func cancelResolution() { - authService.navigator.clear() + /// Used outside the auth navigation stack (e.g. reauthentication), where completion and + /// cancellation are handled by the caller instead of clearing the navigator + init(mfaRequired: MFARequired, onResolved: (() -> Void)?, onCancel: (() -> Void)?) { + self.mfaRequired = mfaRequired + self.onResolved = onResolved + self.onCancel = onCancel } } extension MFAResolutionView: View { public var body: some View { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "lock.shield") - .font(.system(size: 50)) - .foregroundColor(.blue) - - Text("Two-Factor Authentication") - .authFont(.largeTitle) - .fontWeight(.bold) - .accessibilityIdentifier("mfa-resolution-title") - - Text("Complete sign-in with your second factor") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal) - - // MFA Hints Selection (if multiple available) - if mfaRequired.hints.count > 1 { - mfaHintsSelectionView(mfaRequired: mfaRequired) - } - - // Resolution Content - if let hint = selectedHint { - resolutionContent(for: hint) - } - - // Action buttons - VStack(spacing: 12) { - // Complete Resolution Button - Button(action: completeResolution) { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Complete Sign-In") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(!canCompleteResolution) - .accessibilityIdentifier("complete-resolution-button") - - // Cancel Button - Button(action: cancelResolution) { - Text("Cancel") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityIdentifier("cancel-button") - } - .padding(.horizontal) - } - .padding(.vertical, 20) - } - - @ViewBuilder - private func resolutionContent(for hint: MFAHint) -> some View { - switch hint { - case let .phone(displayName, _, phoneNumber): - phoneResolutionContent(displayName: displayName, phoneNumber: phoneNumber) - case let .totp(displayName, _): - totpResolutionContent(displayName: displayName) - } - } - - @ViewBuilder - private func phoneResolutionContent(displayName _: String?, phoneNumber: String?) -> some View { - VStack(spacing: 16) { - VStack(spacing: 8) { - Image(systemName: "message.circle.fill") - .font(.system(size: 40)) - .foregroundColor(.blue) - - Text("SMS Verification") - .authFont(.title2) - .fontWeight(.semibold) - - if let phoneNumber = phoneNumber { - Text("We'll send a code to ••••••\(String(phoneNumber.suffix(4)))") - .authFont(.body) - .foregroundColor(.secondary) - } else { - Text("We'll send a verification code to your phone") - .authFont(.body) - .foregroundColor(.secondary) - } - } - .padding(.horizontal) - - // Send SMS button (if verification ID not yet obtained) - if verificationId == nil { - Button(action: startSMSChallenge) { - HStack { - if isLoading { - ProgressView() - .scaleEffect(0.8) - } - Text("Send Code") - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .disabled(isLoading) - .padding(.horizontal) - .accessibilityIdentifier("send-sms-button") - } else { - // Verification code input - VStack(alignment: .leading, spacing: 8) { - Text("Verification Code") - .authFont(.headline) - - VerificationCodeInputField( - code: $verificationCode, - validations: [FormValidators.verificationCode], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("sms-verification-code-field") - } - .padding(.horizontal) - } - } - } - - @ViewBuilder - private func totpResolutionContent(displayName: String?) -> some View { - VStack(spacing: 16) { - VStack(spacing: 8) { - Image(systemName: "qrcode") - .font(.system(size: 40)) - .foregroundColor(.green) - - Text("Authenticator App") - .authFont(.title2) - .fontWeight(.semibold) - - Text("Enter the 6-digit code from your authenticator app") - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - if let displayName = displayName { - Text(authService.string.accountPrefix(displayName: displayName)) - .authFont(.caption) - .foregroundColor(.secondary) - } - } - .padding(.horizontal) - - // TOTP code input - VStack(alignment: .leading, spacing: 8) { - Text("Verification Code") - .authFont(.headline) - - VerificationCodeInputField( - code: $totpCode, - validations: [FormValidators.verificationCode], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("totp-verification-code-field") - } - .padding(.horizontal) - } - } - - @ViewBuilder - private func mfaHintsSelectionView(mfaRequired: MFARequired) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("Choose verification method:") - .authFont(.headline) - .padding(.horizontal) - - // More idiomatic approach using indices - ForEach(mfaRequired.hints.indices, id: \.self) { index in - let hint = mfaRequired.hints[index] - hintSelectionButton(hint: hint, index: index) - } - } - } - - @ViewBuilder - private func hintSelectionButton(hint: MFAHint, index: Int) -> some View { - Button(action: { - selectedHintIndex = index - // Clear previous input when switching methods - verificationCode = "" - totpCode = "" - verificationId = nil - }) { - HStack { - Image(systemName: hint.isPhoneHint ? "message.circle" : "qrcode") - .foregroundColor(.blue) - - VStack(alignment: .leading) { - Text(hintDisplayName(for: hint)) - .authFont(.body) - .foregroundColor(.primary) - - hintSubtitle(for: hint) - } - - Spacer() - - if selectedHintIndex == index { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) - } - } - .padding() - .background(selectedHintIndex == index ? Color.blue.opacity(0.1) : Color.clear) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(selectedHintIndex == index ? Color.blue : Color.gray.opacity(0.3), lineWidth: 1) - ) - } - .buttonStyle(PlainButtonStyle()) - .padding(.horizontal) - .accessibilityIdentifier("hint-\(index)") - } - - private func hintDisplayName(for hint: MFAHint) -> String { - hint.isPhoneHint ? "SMS" : "Authenticator App" - } - - @ViewBuilder - private func hintSubtitle(for hint: MFAHint) -> some View { - if case let .phone(_, _, phoneNumber) = hint, let phone = phoneNumber { - Text("••••••\(String(phone.suffix(4)))") - .authFont(.caption) - .foregroundColor(.secondary) - } - } -} - -// Helper extension for MFAHint -private extension MFAHint { - var isPhoneHint: Bool { - switch self { - case .phone: - return true - case .totp: - return false - } + MFAResolutionScreen( + mfaRequired: mfaRequired, + onResolved: onResolved, + onCancel: onCancel + ) { DefaultMFAResolutionContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift new file mode 100644 index 00000000000..23f1f95abcd --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryScreen.swift @@ -0,0 +1,161 @@ +// Copyright 2025 Google LLC +// +// 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. + +import SwiftUI + +/// The state and actions of the password recovery screen, passed to +/// ``PasswordRecoveryScreen``'s content. +public struct PasswordRecoveryContentState { + /// The email address to send the reset link to. + public let email: Binding + + /// Sends a password reset link to ``email``, then sets ``didSend``. Does nothing unless + /// ``isValid`` is `true` and ``isLoading`` is `false`. + public let sendResetLink: @MainActor () -> Void + + /// Clears ``didSend`` and ``email`` and returns to the first screen of the sheet. + public let dismissSuccess: @MainActor () -> Void + + /// Clears ``didSend``, keeping ``email`` and staying on this screen, for example when the + /// user swipes a success sheet away. + public let hideSuccess: @MainActor () -> Void + + /// Whether a reset link was sent and neither ``dismissSuccess`` nor ``hideSuccess`` has been + /// called since. + public let didSend: Bool + + /// The address the last reset link was sent to; empty until one is sent. + public let sentEmail: String + + /// Whether ``email`` is a valid email address. + public let isValid: Bool + + /// Whether a reset link is being sent, or the ``AuthService`` is signing in. + public let isLoading: Bool + + /// The localized message of the last failed attempt. It clears when the email changes or the + /// next attempt starts. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(email: Binding, + sendResetLink: @escaping @MainActor () -> Void, + dismissSuccess: @escaping @MainActor () -> Void, + hideSuccess: @escaping @MainActor () -> Void, + didSend: Bool, + sentEmail: String, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.email = email + self.sendResetLink = sendResetLink + self.dismissSuccess = dismissSuccess + self.hideSuccess = hideSuccess + self.didSend = didSend + self.sentEmail = sentEmail + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The password recovery screen, with its content drawn by you. +/// +/// The screen sends the reset link, navigates, and reports errors. Your content only draws the +/// UI from the ``PasswordRecoveryContentState`` it receives. Wrap +/// ``DefaultPasswordRecoveryContent`` to keep the stock markup. +/// +/// Example: +/// ```swift +/// AuthPickerView { authenticatedApp } +/// .pickerDestination { screen in +/// switch screen { +/// case .passwordRecovery: +/// PasswordRecoveryScreen { state in +/// if state.didSend { +/// Text("Check \(state.sentEmail)") +/// Button("OK", action: state.dismissSuccess) +/// } else { +/// TextField("Email", text: state.email) +/// Button("Send reset link", action: state.sendResetLink) +/// .disabled(!state.isValid || state.isLoading) +/// } +/// } +/// default: +/// DefaultAuthPickerDestination(screen: screen) +/// } +/// } +/// ``` +@MainActor +public struct PasswordRecoveryScreen: View { + public init(@ViewBuilder content: @escaping (PasswordRecoveryContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var email = "" + @State private var didSend = false + @State private var sentEmail = "" + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (PasswordRecoveryContentState) -> Content + + public var body: some View { + content(PasswordRecoveryContentState( + email: $email, + sendResetLink: sendResetLink, + dismissSuccess: dismissSuccess, + hideSuccess: { didSend = false }, + didSend: didSend, + sentEmail: sentEmail, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .onChange(of: email) { errorMessage = nil } + } + + private var isValid: Bool { + CommonUtils.isValidEmail(email) + } + + private var isLoading: Bool { + isWorking || authService.authenticationState == .authenticating + } + + private func sendResetLink() { + guard isValid, !isLoading 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 dismissSuccess() { + didSend = false + email = "" + authService.navigator.clear() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift index 73a60758e6e..93a9a4f5af1 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PasswordRecoveryView.swift @@ -12,95 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PasswordRecoveryView { - @Environment(AuthService.self) private var authService - @State private var email = "" - @State private var showSuccessSheet = false - @State private var sentEmail = "" - public init() {} - - private func sendPasswordRecoveryEmail() async { - do { - try await authService.sendPasswordRecoveryEmail(email: email) - sentEmail = email - showSuccessSheet = true - } catch { - // Error already displayed via modal by AuthService - } - } } extension PasswordRecoveryView: View { public var body: some View { - VStack(spacing: 24) { - AuthTextField( - text: $email, - label: authService.string.passwordRecoveryEmailFieldLabel, - prompt: authService.string.emailInputLabel, - keyboardType: .emailAddress, - contentType: .emailAddress, - validations: [ - FormValidators.email, - ], - leading: { - Image(systemName: "at") - } - ) - Button(action: { - Task { - await sendPasswordRecoveryEmail() - } - }) { - Text(authService.string.forgotPasswordInputLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .disabled(!CommonUtils.isValidEmail(email)) - .frame(maxWidth: .infinity) - .authCTAButtonStyle() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationTitle(authService.string.passwordRecoveryTitle) - .safeAreaPadding() - .sheet(isPresented: $showSuccessSheet) { - successSheet - } - } - - @ViewBuilder - @MainActor - private var successSheet: some View { - VStack { - Text(authService.string.passwordRecoveryEmailSentTitle) - .authFont(.largeTitle) - .fontWeight(.bold) - .padding() - Text(authService.string.passwordRecoveryHelperMessage) - .authCustomFont(.body) - .padding() - - Divider() - - Text(String(format: authService.string.passwordRecoveryEmailSentMessage, sentEmail)) - .authCustomFont(.body) - .padding() - - Divider() - - Button(authService.string.okButtonLabel) { - showSuccessSheet = false - email = "" - authService.navigator.clear() - } - .padding() - } - .padding() + PasswordRecoveryScreen { DefaultPasswordRecoveryContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift new file mode 100644 index 00000000000..dcf6a0642a3 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthScreen.swift @@ -0,0 +1,170 @@ +// Copyright 2025 Google LLC +// +// 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. + +@preconcurrency import FirebaseAuth +import SwiftUI + +/// The state and actions of the phone reauthentication prompt, passed to +/// ``PhoneReauthScreen``'s content. +public struct PhoneReauthContentState { + /// The code the user typed. + public let verificationCode: Binding + + /// Sends a verification code to ``phoneNumber``, or a new one once ``codeSent`` is `true`. + /// Does nothing while ``isLoading`` is `true`. + public let sendCode: @MainActor () -> Void + + /// Reauthenticates with ``verificationCode``, pushing second-factor resolution when the user + /// has MFA. Does nothing unless ``codeSent`` and ``isValid`` are `true` and ``isLoading`` is + /// `false`. + public let verify: @MainActor () -> Void + + /// Cancels reauthentication and dismisses the prompt. + public let cancel: @MainActor () -> Void + + /// The phone number of the signed-in user. + public let phoneNumber: String + + /// Whether a code was sent, so the content should ask for it. + public let codeSent: Bool + + /// Whether ``verificationCode`` has 6 characters. + public let isValid: Bool + + /// Whether a send or verify is in flight. + public let isLoading: Bool + + /// The localized message of the last failed send or verify. It clears when the code changes + /// or the next attempt starts. The prompt's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(verificationCode: Binding, + sendCode: @escaping @MainActor () -> Void, + verify: @escaping @MainActor () -> Void, + cancel: @escaping @MainActor () -> Void, + phoneNumber: String, + codeSent: Bool, + isValid: Bool, + isLoading: Bool, + errorMessage: String?) { + self.verificationCode = verificationCode + self.sendCode = sendCode + self.verify = verify + self.cancel = cancel + self.phoneNumber = phoneNumber + self.codeSent = codeSent + self.isValid = isValid + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// The phone reauthentication prompt, with its content drawn by you. +/// +/// The screen sends the code, reauthenticates, resolves a second factor, and reports errors. +/// Your content only draws the UI from the ``PhoneReauthContentState`` it receives. Apps usually +/// supply content with `reauthContent(_:)` rather than creating this screen. +@MainActor +public struct PhoneReauthScreen: View { + public init(phoneNumber: String, + coordinator: ReauthenticationCoordinator, + @ViewBuilder content: @escaping (PhoneReauthContentState) -> Content) { + self.phoneNumber = phoneNumber + self.coordinator = coordinator + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var verificationID: String? + @State private var verificationCode = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var error: AlertError? + @State private var mfaRequired: MFARequired? + + private let phoneNumber: String + private let coordinator: ReauthenticationCoordinator + private let content: (PhoneReauthContentState) -> Content + + public var body: some View { + NavigationStack { + content(PhoneReauthContentState( + verificationCode: $verificationCode, + sendCode: sendCode, + verify: verify, + cancel: { coordinator.reauthCancelled() }, + phoneNumber: phoneNumber, + codeSent: verificationID != nil, + isValid: isValid, + isLoading: isLoading, + errorMessage: errorMessage + )) + .mfaReauthResolution($mfaRequired, coordinator: coordinator) + } + .onChange(of: verificationCode) { errorMessage = nil } + .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) + } + + private var isValid: Bool { + verificationCode.count == 6 + } + + private func sendCode() { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + Task { + defer { isLoading = false } + do { + verificationID = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) + } catch { + report(error) + } + } + } + + private func verify() { + guard let verificationID, isValid, !isLoading else { return } + isLoading = true + errorMessage = nil + Task { + defer { isLoading = false } + do { + let credential = PhoneAuthProvider.provider() + .credential(withVerificationID: verificationID, verificationCode: verificationCode) + try await authService.reauthenticate(with: credential) + coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + self.mfaRequired = mfaRequired + } catch { + report(error) + } + } + } + + private func report(_ error: Error) { + errorMessage = authService.string.localizedErrorMessage(for: error) + if let reportError { + reportError(error) + } else { + self.error = AlertError( + title: "Error", + message: error.localizedDescription, + underlyingError: error + ) + } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift index deed8e5799d..d8941b5b1d5 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/PhoneReauthView.swift @@ -12,192 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -@preconcurrency import FirebaseAuth -import FirebaseAuthUIComponents import FirebaseCore import SwiftUI @MainActor public struct PhoneReauthView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - let phoneNumber: String let coordinator: ReauthenticationCoordinator - - @State private var verificationID: String? - @State private var verificationCode = "" - @State private var isLoading = false - @State private var error: AlertError? - - private func sendSMS() { - Task { @MainActor in - isLoading = true - do { - let vid = try await authService.verifyPhoneNumber(phoneNumber: phoneNumber) - verificationID = vid - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - } - - private func verifyCode() { - guard let verificationID = verificationID else { return } - - Task { @MainActor in - isLoading = true - do { - let credential = PhoneAuthProvider.provider() - .credential(withVerificationID: verificationID, verificationCode: verificationCode) - - try await authService.reauthenticate(with: credential) - coordinator.reauthCompleted() - isLoading = false - } catch { - if let reportError = reportError { - reportError(error) - } else { - self.error = AlertError( - title: "Error", - message: error.localizedDescription, - underlyingError: error - ) - } - isLoading = false - } - } - } } extension PhoneReauthView: View { public var body: some View { - NavigationStack { - VStack(spacing: 24) { - // Header - VStack(spacing: 12) { - Image(systemName: "phone.circle.fill") - .font(.system(size: 60)) - .foregroundColor(.blue) - - Text(authService.string.verifyPhoneNumberTitle) - .authFont(.title) - .fontWeight(.bold) - - Text(authService.string.forSecurityVerifyPhoneMessage) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding() - - if verificationID == nil { - // Initial state - sending SMS - VStack(spacing: 20) { - Text(authService.string.sendVerificationCodeToPhonePrefix) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(phoneNumber) - .authFont(.headline) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - Button(action: { - sendSMS() - }) { - if isLoading { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.sendVerificationCodeButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(isLoading) - .accessibilityIdentifier("send-verification-code-button") - Button(authService.string.cancelButtonLabel) { - coordinator.reauthCancelled() - } - } - .padding(.horizontal) - } else { - // Enter verification code - VStack(spacing: 20) { - Text(authService.string.enterSixDigitCodeSentToPrefix) - .authFont(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(phoneNumber) - .authFont(.caption) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 8) - - VerificationCodeInputField( - code: $verificationCode, - validations: [ - FormValidators.verificationCode, - ], - maintainsValidationMessage: true - ) - .accessibilityIdentifier("verification-code-field") - - Button(action: { - verifyCode() - }) { - if isLoading { - ProgressView() - .frame(height: 32) - .frame(maxWidth: .infinity) - } else { - Text(authService.string.verifyButtonLabel) - .frame(height: 32) - .frame(maxWidth: .infinity) - } - } - .authCTAButtonStyle() - .disabled(verificationCode.count != 6 || isLoading) - .accessibilityIdentifier("verify-button") - - Button(action: { - sendSMS() - }) { - Text("Resend Code") - .frame(height: 32) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(isLoading) - .accessibilityIdentifier("resend-code-button") - - Button(authService.string.cancelButtonLabel) { - coordinator.reauthCancelled() - } - } - .padding(.horizontal) - } - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationBarTitleDisplayMode(.inline) + PhoneReauthScreen(phoneNumber: phoneNumber, coordinator: coordinator) { + DefaultPhoneReauthContent(state: $0) } - .errorAlert(error: $error, okButtonLabel: authService.string.okButtonLabel) } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift new file mode 100644 index 00000000000..2002045cb00 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthContent.swift @@ -0,0 +1,104 @@ +// Copyright 2025 Google LLC +// +// 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. + +import SwiftUI + +/// A reauthentication prompt and its state, passed to the content given to +/// ``SwiftUICore/View/reauthContent(_:)``. +public enum ReauthPrompt { + /// The user signed in with email and password. + case password(EmailReauthContentState) + /// The user signed in with a phone number. + case phone(PhoneReauthContentState) + /// The user signed in with an email link. + case emailLink(EmailLinkReauthContentState) +} + +/// The stock content for a reauthentication prompt, for the cases your +/// ``SwiftUICore/View/reauthContent(_:)`` content does not replace. +@MainActor +public struct DefaultReauthContent: View { + public init(prompt: ReauthPrompt) { + self.prompt = prompt + } + + private let prompt: ReauthPrompt + + public var body: some View { + switch prompt { + case let .password(state): + DefaultEmailReauthContent(state: state) + case let .phone(state): + DefaultPhoneReauthContent(state: state) + case let .emailLink(state): + DefaultEmailLinkReauthContent(state: state) + } + } +} + +private struct ReauthContentKey: @preconcurrency EnvironmentKey { + @MainActor static let defaultValue: (@MainActor (ReauthPrompt) -> AnyView)? = nil +} + +extension EnvironmentValues { + var reauthContent: (@MainActor (ReauthPrompt) -> AnyView)? { + get { self[ReauthContentKey.self] } + set { self[ReauthContentKey.self] = newValue } + } +} + +/// Draws the content of a reauthentication prompt, using the app's content when one was given. +struct ReauthPromptContent: View { + @Environment(\.reauthContent) private var reauthContent + let prompt: ReauthPrompt + + var body: some View { + if let reauthContent { + reauthContent(prompt) + } else { + DefaultReauthContent(prompt: prompt) + } + } +} + +public extension View { + /// Replaces the content of the password, phone, and email link reauthentication prompts shown + /// inside this view. + /// + /// Reauthentication is asked for when a sensitive action, such as deleting the account or + /// changing the password, needs a recent sign-in. The prompts stay sheets that verify the + /// user, resolve a second factor, and report errors. Your content only draws the UI from the + /// state in the ``ReauthPrompt`` it receives. Return ``DefaultReauthContent`` for prompts you + /// do not replace. + /// + /// Example: + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .reauthContent { prompt in + /// switch prompt { + /// case let .password(state): + /// SecureField("Password", text: state.password) + /// Button("Confirm", action: state.verify) + /// .disabled(!state.isValid || state.isLoading) + /// Button("Cancel", action: state.cancel) + /// default: + /// DefaultReauthContent(prompt: prompt) + /// } + /// } + /// ``` + func reauthContent(@ViewBuilder _ content: @escaping @MainActor (ReauthPrompt) + -> Content) -> some View { + environment(\.reauthContent) { AnyView(content($0)) } + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift index 2e14fa17f4d..6d54627a178 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationCoordinator.swift @@ -26,6 +26,8 @@ public final class ReauthenticationCoordinator { public var showingEmailPasswordPrompt = false public var showingEmailLinkReauth = false public var showingEmailLinkReauthAlert = false + var showingMFAReauth = false + var mfaReauthRequired: MFARequired? private var continuation: CheckedContinuation? @@ -64,6 +66,12 @@ public final class ReauthenticationCoordinator { showingEmailLinkReauth = true } + /// Called when reauthentication outside a reauth sheet (OAuth) requires a second factor + func requireMFAResolution(_ mfaRequired: MFARequired) { + mfaReauthRequired = mfaRequired + showingMFAReauth = true + } + /// Called when reauthentication completes successfully public func reauthCompleted() { continuation?.resume() @@ -84,6 +92,8 @@ public final class ReauthenticationCoordinator { showingEmailPasswordPrompt = false showingEmailLinkReauth = false showingEmailLinkReauthAlert = false + showingMFAReauth = false + mfaReauthRequired = nil reauthContext = nil } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift index b20b23a59f0..f153e447e4d 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/ReauthenticationModifier.swift @@ -55,21 +55,23 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for phone reauthentication - .sheet(isPresented: $coordinator.showingPhoneReauth) { + .sheet(isPresented: $coordinator.showingPhoneReauth, onDismiss: cancelIfPending) { if case let .phone(context) = coordinator.reauthContext { - PhoneReauthView( - phoneNumber: context.phoneNumber, - coordinator: coordinator - ) + PhoneReauthScreen(phoneNumber: context.phoneNumber, coordinator: coordinator) { + ReauthPromptContent(prompt: .phone($0)) + } + // Show errors inside the sheet; a parent's reportError alert would sit behind it + .environment(\.reportError, nil) } } // Sheet for email reauthentication - .sheet(isPresented: $coordinator.showingEmailPasswordPrompt) { + .sheet(isPresented: $coordinator.showingEmailPasswordPrompt, onDismiss: cancelIfPending) { if case let .email(context) = coordinator.reauthContext { - EmailReauthView( - email: context.email, - coordinator: coordinator - ) + EmailReauthScreen(email: context.email, coordinator: coordinator) { + ReauthPromptContent(prompt: .password($0)) + } + // Show errors inside the sheet; a parent's reportError alert would sit behind it + .environment(\.reportError, nil) } } // Alert for email link reauthentication @@ -89,14 +91,38 @@ struct ReauthenticationModifier: ViewModifier { } } // Sheet for email link reauthentication - .sheet(isPresented: $coordinator.showingEmailLinkReauth) { + .sheet(isPresented: $coordinator.showingEmailLinkReauth, onDismiss: cancelIfPending) { if case let .emailLink(context) = coordinator.reauthContext { - EmailLinkReauthView( - email: context.email, - coordinator: coordinator - ) + EmailLinkReauthScreen(email: context.email, coordinator: coordinator) { + ReauthPromptContent(prompt: .emailLink($0)) + } + // Show errors inside the sheet; a parent's reportError alert would sit behind it + .environment(\.reportError, nil) } } + // Sheet for second-factor resolution after OAuth reauthentication + .sheet(isPresented: $coordinator.showingMFAReauth, onDismiss: { + // No-op after completion; resumes the caller when swiped away + coordinator.reauthCancelled() + }) { + if let mfaRequired = coordinator.mfaReauthRequired { + NavigationStack { + MFAResolutionView( + mfaRequired: mfaRequired, + onResolved: { coordinator.reauthCompleted() }, + onCancel: { coordinator.reauthCancelled() } + ) + } + .environment(\.reportError, nil) + } + } + } + + /// Treats a sheet swiped away mid-flow as a cancel, so the waiting operation finishes. + private func cancelIfPending() { + if coordinator.reauthContext != nil { + coordinator.reauthCancelled() + } } private func performReauth() { @@ -107,6 +133,8 @@ struct ReauthenticationModifier: ViewModifier { // For OAuth providers (Google, Apple, etc.), call reauthenticate with context try await authService.reauthenticate(context: context) coordinator.reauthCompleted() + } catch let AuthServiceError.multiFactorReauthenticationRequired(mfaRequired) { + coordinator.requireMFAResolution(mfaRequired) } catch { coordinator.reauthCancelled() } @@ -114,6 +142,32 @@ struct ReauthenticationModifier: ViewModifier { } } +/// Pushes second-factor resolution inside a reauthentication sheet +struct MFAReauthResolutionModifier: ViewModifier { + @Binding var mfaRequired: MFARequired? + let coordinator: ReauthenticationCoordinator + + func body(content: Content) -> some View { + content + .navigationDestination(item: $mfaRequired) { mfaRequired in + MFAResolutionView( + mfaRequired: mfaRequired, + onResolved: { coordinator.reauthCompleted() }, + onCancel: { coordinator.reauthCancelled() } + ) + // Going back would return to a credential step that has already been used + .navigationBarBackButtonHidden() + } + } +} + +extension View { + func mfaReauthResolution(_ mfaRequired: Binding, + coordinator: ReauthenticationCoordinator) -> some View { + modifier(MFAReauthResolutionModifier(mfaRequired: mfaRequired, coordinator: coordinator)) + } +} + public extension View { /// Adds reauthentication handling to the view /// - Parameter coordinator: The coordinator managing the reauthentication state diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift new file mode 100644 index 00000000000..dae247c7e5d --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignInView.swift @@ -0,0 +1,43 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseCore +import SwiftUI + +/// The default ``AuthView/signIn`` screen: the stock email and password form, fixed to sign-in. +/// Its "Sign up" link pushes ``AuthView/signUp``. +@MainActor +public struct SignInView { + public init() {} + + @Environment(AuthService.self) private var authService +} + +extension SignInView: View { + public var body: some View { + ScrollView { + EmailAuthScreen(flow: .signIn) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signInWithEmailButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + SignInView() + } + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift new file mode 100644 index 00000000000..f04e740affb --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignUpView.swift @@ -0,0 +1,42 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseCore +import SwiftUI + +/// The default ``AuthView/signUp`` screen: the stock email and password form, fixed to sign-up. +@MainActor +public struct SignUpView { + public init() {} + + @Environment(AuthService.self) private var authService +} + +extension SignUpView: View { + public var body: some View { + ScrollView { + EmailAuthScreen(flow: .signUp) { DefaultEmailAuthContent(state: $0) } + } + .safeAreaPadding() + .navigationTitle(authService.string.signUpWithEmailButtonLabel) + } +} + +#Preview { + FirebaseOptions.dummyConfigurationForPreview() + return NavigationStack { + SignUpView() + } + .environment(AuthService()) +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift new file mode 100644 index 00000000000..831ec64adc6 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInScreen.swift @@ -0,0 +1,247 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import SwiftUI + +/// The state and actions of the signed-in screen, passed to the content of +/// ``AuthPickerView/signedInContent(_:)``. +public struct SignedInContentState { + /// Signs the user out. Does nothing while ``isLoading`` is `true`. + public let signOut: @MainActor () -> Void + + /// Asks the user to confirm, then deletes the account, reauthenticating first when Firebase + /// requires it. Does nothing while ``isLoading`` is `true`. + public let deleteAccount: @MainActor () -> Void + + /// Pushes ``AuthView/updatePassword``. + public let updatePassword: @MainActor () -> Void + + /// Sends a verification email to the user, then shows an alert telling them to tap the link. + /// Does nothing while ``isLoading`` is `true`. + public let verifyEmail: @MainActor () -> Void + + /// Pushes ``AuthView/mfaManagement``. Offer it only when ``isMFAEnabled`` is `true`. + public let goToMFAManagement: @MainActor () -> Void + + /// The signed-in user. + public let user: User? + + /// Whether the user's email is verified; `true` when there is no user. + public let isEmailVerified: Bool + + /// Whether ``AuthConfiguration/mfaEnabled`` is set. Offer ``goToMFAManagement`` only when it is + /// `true`. + public let isMFAEnabled: Bool + + /// Whether a sign out, account deletion or verification email is in flight. + public let isLoading: Bool + + /// The localized message of the last failed sign out, account deletion or verification email. + /// It clears when the next of those starts, and stays `nil` when the user cancels + /// reauthentication. The sheet's error alert still shows for the same failure. + public let errorMessage: String? + + /// Creates a state, for example to preview custom content with stub actions. + public init(signOut: @escaping @MainActor () -> Void, + deleteAccount: @escaping @MainActor () -> Void, + updatePassword: @escaping @MainActor () -> Void, + verifyEmail: @escaping @MainActor () -> Void, + goToMFAManagement: @escaping @MainActor () -> Void, + user: User?, + isEmailVerified: Bool, + isMFAEnabled: Bool, + isLoading: Bool, + errorMessage: String?) { + self.signOut = signOut + self.deleteAccount = deleteAccount + self.updatePassword = updatePassword + self.verifyEmail = verifyEmail + self.goToMFAManagement = goToMFAManagement + self.user = user + self.isEmailVerified = isEmailVerified + self.isMFAEnabled = isMFAEnabled + self.isLoading = isLoading + self.errorMessage = errorMessage + } +} + +/// Runs the signed-in screen's actions, delete confirmation, reauthentication and alerts, and +/// draws its content from the ``SignedInContentState`` it builds. +@MainActor +struct SignedInScreen: View { + init(@ViewBuilder content: @escaping (SignedInContentState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.reportError) private var reportError + @State private var showDeleteConfirmation = false + @State private var showEmailVerificationSent = false + @State private var reauthCoordinator = ReauthenticationCoordinator() + @State private var isWorking = false + @State private var errorMessage: String? + + private let content: (SignedInContentState) -> Content + + var body: some View { + // A single container, so the sheet and alerts below attach once even when the content is + // several views. + VStack { + content(state) + } + .withReauthentication(coordinator: reauthCoordinator) + .sheet(isPresented: $showDeleteConfirmation) { + DeleteAccountConfirmationSheet( + onConfirm: { + showDeleteConfirmation = false + deleteAccount() + }, + onCancel: { + showDeleteConfirmation = false + } + ) + .presentationDetents([.medium]) + } + .alert( + authService.string.verifyEmailSheetMessage, + isPresented: $showEmailVerificationSent + ) { + Button(authService.string.okButtonLabel) { + showEmailVerificationSent = false + } + } message: { + Text(authService.string.emailVerificationTapLinkMessage) + } + } + + private var state: SignedInContentState { + SignedInContentState( + signOut: signOut, + deleteAccount: { + if !isWorking { + showDeleteConfirmation = true + } + }, + updatePassword: { authService.navigator.push(.updatePassword) }, + verifyEmail: verifyEmail, + goToMFAManagement: { authService.navigator.push(.mfaManagement) }, + user: authService.currentUser, + isEmailVerified: authService.currentUser?.isEmailVerified != false, + isMFAEnabled: authService.configuration.mfaEnabled, + isLoading: isWorking, + errorMessage: errorMessage + ) + } + + private func signOut() { + perform { try await authService.signOut() } + } + + private func verifyEmail() { + perform { + try await authService.sendEmailVerification() + showEmailVerificationSent = true + } + } + + private func deleteAccount() { + perform { + try await withReauthenticationIfNeeded( + authService: authService, + coordinator: reauthCoordinator + ) { + try await authService.deleteUser() + } + } + } + + private func perform(_ action: @escaping @MainActor () async throws -> Void) { + guard !isWorking else { return } + isWorking = true + errorMessage = nil + Task { + defer { isWorking = false } + do { + try await action() + } catch { + if case AuthServiceError.signInCancelled = error { + // The user dismissed reauthentication; the sheet still reports it, as before. + } else { + errorMessage = authService.string.localizedErrorMessage(for: error) + } + reportError?(error) + } + } + } +} + +private struct DeleteAccountConfirmationSheet: View { + @Environment(AuthService.self) private var authService + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Delete Account?") + .authFont(.title) + .fontWeight(.bold) + + Text( + "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." + ) + .authFont(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + VStack(spacing: 12) { + Button { + onConfirm() + } label: { + Text("Delete Account") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("confirm-delete-button") + + Button { + onCancel() + } label: { + Text("Cancel") + .authCustomFont(.body) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .padding([.top, .bottom], 8) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("cancel-delete-button") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .safeAreaPadding() + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift index 45ebffb82e5..43198057981 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/SignedInView.swift @@ -15,213 +15,17 @@ import FirebaseCore import SwiftUI +/// The stock signed-in screen: ``DefaultSignedInContent`` with the library's account actions. The +/// authentication sheet shows it to signed-in users unless you replace it with +/// ``AuthPickerView/signedInContent(_:)``. @MainActor public struct SignedInView { - @Environment(AuthService.self) private var authService - @Environment(\.reportError) private var reportError - @State private var showDeleteConfirmation = false - @State private var showEmailVerificationSent = false - @State private var reauthCoordinator = ReauthenticationCoordinator() - - private func sendEmailVerification() async throws { - do { - try await authService.sendEmailVerification() - showEmailVerificationSent = true - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } + public init() {} } extension SignedInView: View { public var body: some View { - VStack { - Text(authService.string.signedInTitle) - .authFont(.largeTitle) - .fontWeight(.bold) - .padding() - .accessibilityIdentifier("signed-in-text") - Text( - "\(authService.currentUser?.email ?? authService.currentUser?.displayName ?? authService.currentUser?.phoneNumber ?? "")" - ) - .authCustomFont(.body) - if authService.currentUser?.isEmailVerified == false { - Button { - Task { - try await sendEmailVerification() - } - } label: { - Text(authService.string.sendEmailVerificationButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("verify-email-button") - } - Button { - authService.navigator.push(.updatePassword) - } label: { - Text(authService.string.updatePasswordButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("update-password-button") - - if authService.configuration.mfaEnabled { - Button { - authService.navigator.push(.mfaManagement) - } label: { - Text(authService.string.manageTwoFactorAuthenticationLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("mfa-management-button") - } - - Button { - showDeleteConfirmation = true - } label: { - Text(authService.string.deleteAccountButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("delete-account-button") - - Button { - Task { - do { - try await authService.signOut() - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - } label: { - Text(authService.string.signOutButtonLabel) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .authCTAButtonStyle() - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("sign-out-button") - } - .safeAreaPadding() - .withReauthentication(coordinator: reauthCoordinator) - .sheet(isPresented: $showDeleteConfirmation) { - DeleteAccountConfirmationSheet( - onConfirm: { - showDeleteConfirmation = false - Task { - do { - try await withReauthenticationIfNeeded( - authService: authService, - coordinator: reauthCoordinator - ) { - try await authService.deleteUser() - } - } catch { - if let errorHandler = reportError { - errorHandler(error) - } else { - throw error - } - } - } - }, - onCancel: { - showDeleteConfirmation = false - } - ) - .presentationDetents([.medium]) - } - .alert( - authService.string.verifyEmailSheetMessage, - isPresented: $showEmailVerificationSent - ) { - Button(authService.string.okButtonLabel) { - showEmailVerificationSent = false - } - } message: { - Text(authService.string.emailVerificationTapLinkMessage) - } - } -} - -private struct DeleteAccountConfirmationSheet: View { - @Environment(AuthService.self) private var authService - let onConfirm: () -> Void - let onCancel: () -> Void - - var body: some View { - VStack(spacing: 24) { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 60)) - .foregroundColor(.red) - - Text("Delete Account?") - .authFont(.title) - .fontWeight(.bold) - - Text( - "This action cannot be undone. All your data will be permanently deleted. You may need to reauthenticate to complete this action." - ) - .authFont(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - VStack(spacing: 12) { - Button { - onConfirm() - } label: { - Text("Delete Account") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("confirm-delete-button") - - Button { - onCancel() - } label: { - Text("Cancel") - .authCustomFont(.body) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .padding([.top, .bottom], 8) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("cancel-delete-button") - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .safeAreaPadding() + SignedInScreen { DefaultSignedInContent(state: $0) } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift new file mode 100644 index 00000000000..89c667e6b1c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/MFAEnrolmentScreenTests.swift @@ -0,0 +1,138 @@ +// Copyright 2025 Google LLC +// +// 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. + +@testable import FirebaseAuthSwiftUI +import Testing + +private typealias Step = MFAEnrollmentContentState.Step + +@Suite("MFAEnrolmentScreen Tests") +struct MFAEnrolmentScreenTests { + @Test("Allowed factors list SMS first") + func sortedFactorsListSMSFirst() { + #expect(MFAEnrollmentContentState.sortedFactors([.totp, .sms]) == [.sms, .totp]) + #expect(MFAEnrollmentContentState.sortedFactors([.totp]) == [.totp]) + #expect(MFAEnrollmentContentState.sortedFactors([]).isEmpty) + } + + @Test("SMS sessions map to the phone number and code steps") + func smsSessionSteps() { + #expect(Step(type: .sms, status: .initiated, isExpired: false) == .enterPhoneNumber) + #expect(Step(type: .sms, status: .verificationSent, isExpired: false) + == .enterVerificationCode) + #expect(Step(type: .sms, status: .verificationPending, isExpired: false) + == .enterVerificationCode) + } + + @Test("TOTP sessions map to the authenticator step") + func totpSessionSteps() { + #expect(Step(type: .totp, status: .initiated, isExpired: false) == .setUpTOTP) + #expect(Step(type: .totp, status: .verificationPending, isExpired: false) == .setUpTOTP) + } + + @Test("Finished sessions go back to factor selection") + func finishedSessionsSelectFactor() { + let statuses: [EnrollmentSession.EnrollmentStatus] = [.completed, .failed, .expired] + for status in statuses { + #expect(Step(type: .sms, status: status, isExpired: false) == .selectFactor) + #expect(Step(type: .totp, status: status, isExpired: false) == .selectFactor) + } + } + + @Test("Expired sessions go back to factor selection") + func expiredSessionsSelectFactor() { + #expect(Step(type: .sms, status: .initiated, isExpired: true) == .selectFactor) + #expect(Step(type: .sms, status: .verificationSent, isExpired: true) == .selectFactor) + #expect(Step(type: .totp, status: .initiated, isExpired: true) == .selectFactor) + } + + @Test("Factor selection needs MFA enabled and an allowed factor") + func selectFactorValidity() { + #expect(isValid(.selectFactor)) + #expect(!isValid(.selectFactor, isMFAEnabled: false)) + #expect(!isValid(.selectFactor, allowedFactors: [])) + #expect(!isValid(.selectFactor, allowedFactors: [.sms], selectedFactorType: .totp)) + } + + @Test("Phone number step needs a number and a display name") + func enterPhoneNumberValidity() { + #expect(isValid(.enterPhoneNumber, phoneNumber: "5551234567", displayName: "Phone")) + #expect(!isValid(.enterPhoneNumber, phoneNumber: "", displayName: "Phone")) + #expect(!isValid(.enterPhoneNumber, phoneNumber: "5551234567", displayName: "")) + } + + @Test("Code steps need their own code and a display name") + func codeStepValidity() { + #expect(isValid(.enterVerificationCode, verificationCode: "123456", displayName: "Phone")) + #expect(!isValid(.enterVerificationCode, totpCode: "123456", displayName: "Phone")) + #expect(!isValid(.enterVerificationCode, verificationCode: "123456", displayName: "")) + #expect(isValid(.setUpTOTP, totpCode: "123456", displayName: "App")) + #expect(!isValid(.setUpTOTP, verificationCode: "123456", displayName: "App")) + #expect(!isValid(.setUpTOTP, totpCode: "123456", displayName: "")) + } + + @Test("Get Started runs only from factor selection") + func startEnrollmentGuard() { + #expect(Step.selectFactor.canStartEnrollment(isValid: true, isLoading: false)) + #expect(!Step.selectFactor.canStartEnrollment(isValid: false, isLoading: false)) + #expect(!Step.selectFactor.canStartEnrollment(isValid: true, isLoading: true)) + for step in [Step.enterPhoneNumber, .enterVerificationCode, .setUpTOTP] { + #expect(!step.canStartEnrollment(isValid: true, isLoading: false)) + } + } + + @Test("Codes are sent only from the SMS steps") + func sendCodeGuard() { + for step in [Step.enterPhoneNumber, .enterVerificationCode] { + #expect(step.canSendCode(phoneNumber: "5551234567", displayName: "Phone", isLoading: false)) + #expect(!step.canSendCode(phoneNumber: "", displayName: "Phone", isLoading: false)) + #expect(!step.canSendCode(phoneNumber: "5551234567", displayName: "", isLoading: false)) + #expect(!step.canSendCode(phoneNumber: "5551234567", displayName: "Phone", isLoading: true)) + } + for step in [Step.selectFactor, .setUpTOTP] { + #expect(!step.canSendCode(phoneNumber: "5551234567", displayName: "Phone", isLoading: false)) + } + } + + @Test("Enrolment runs only from the code steps") + func enrollGuard() { + for step in [Step.enterVerificationCode, .setUpTOTP] { + #expect(step.canEnroll(isValid: true, isLoading: false)) + #expect(!step.canEnroll(isValid: false, isLoading: false)) + #expect(!step.canEnroll(isValid: true, isLoading: true)) + } + for step in [Step.selectFactor, .enterPhoneNumber] { + #expect(!step.canEnroll(isValid: true, isLoading: false)) + } + } + + private func isValid(_ step: Step, + isMFAEnabled: Bool = true, + allowedFactors: [SecondFactorType] = [.sms, .totp], + selectedFactorType: SecondFactorType = .sms, + phoneNumber: String = "", + verificationCode: String = "", + totpCode: String = "", + displayName: String = "") -> Bool { + step.isValid( + isMFAEnabled: isMFAEnabled, + allowedFactors: allowedFactors, + selectedFactorType: selectedFactorType, + phoneNumber: phoneNumber, + verificationCode: verificationCode, + totpCode: totpCode, + displayName: displayName + ) + } +} diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift new file mode 100644 index 00000000000..ccf3e63f55f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/ReauthContentTests.swift @@ -0,0 +1,181 @@ +// Copyright 2025 Google LLC +// +// 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. + +@testable import FirebaseAuthSwiftUI +import FirebaseCore +import SwiftUI +import Testing + +/// Renders `view` in a window so its body and environment are evaluated, and keeps it on screen +/// until the returned window is released. +@MainActor +private func host(_ view: some View) -> UIWindow { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = UIHostingController(rootView: view) + window.makeKeyAndVisible() + window.layoutIfNeeded() + return window +} + +/// Lets SwiftUI apply pending state changes and re-render. +@MainActor +private func settle() { + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) +} + +@MainActor +private final class Recorder { + var value: Value? + + /// Stores `value` while content is drawn, standing in for the app's content. + func capture(_ value: Value) -> some View { + self.value = value + return EmptyView() + } +} + +@Suite("Reauth screens") +@MainActor +struct ReauthScreenTests { + init() { + FirebaseOptions.dummyConfigurationForPreview() + } + + @Test("Password screen is valid once a password is entered") + func passwordValidity() { + let state = Recorder() + let window = host( + EmailReauthScreen(email: "test@example.com", coordinator: ReauthenticationCoordinator()) { + state.capture($0) + } + .environment(AuthService()) + ) + defer { window.isHidden = true } + + #expect(state.value?.email == "test@example.com") + #expect(state.value?.isValid == false) + #expect(state.value?.isLoading == false) + #expect(state.value?.errorMessage == nil) + + state.value?.password.wrappedValue = "secret" + settle() + + #expect(state.value?.isValid == true) + } + + @Test("Phone screen asks for a code only after one is sent, and needs 6 characters") + func phoneValidity() { + let state = Recorder() + let window = host( + PhoneReauthScreen(phoneNumber: "+15555550100", coordinator: ReauthenticationCoordinator()) { + state.capture($0) + } + .environment(AuthService()) + ) + defer { window.isHidden = true } + + #expect(state.value?.phoneNumber == "+15555550100") + #expect(state.value?.codeSent == false) + #expect(state.value?.isValid == false) + + state.value?.verificationCode.wrappedValue = "12345" + settle() + #expect(state.value?.isValid == false) + + state.value?.verificationCode.wrappedValue = "123456" + settle() + #expect(state.value?.isValid == true) + } + + @Test("Cancel ends the pending reauthentication request") + func cancelEndsRequest() async { + let coordinator = ReauthenticationCoordinator() + let state = Recorder() + let window = host( + EmailReauthScreen(email: "test@example.com", coordinator: coordinator) { + state.capture($0) + } + .environment(AuthService()) + ) + defer { window.isHidden = true } + + let request = Task { + try await coordinator + .requestReauth(context: .email(EmailReauthContext(email: "test@example.com"))) + } + await Task.yield() + #expect(coordinator.showingEmailPasswordPrompt) + + state.value?.cancel() + + await #expect(throws: AuthServiceError.self) { try await request.value } + #expect(!coordinator.showingEmailPasswordPrompt) + } +} + +@Suite("Reauth content slot") +@MainActor +struct ReauthContentSlotTests { + private let prompt = ReauthPrompt.emailLink(EmailLinkReauthContentState( + resendLink: {}, + cancel: {}, + email: "test@example.com", + linkSent: true, + isLoading: false, + errorMessage: nil + )) + + @Test("No reauth content is set by default") + func defaultIsNil() { + #expect(EnvironmentValues().reauthContent == nil) + } + + @Test("Prompts inside reauthContent are drawn by the app's content") + func usesAppContent() { + let received = Recorder() + let window = host( + ReauthPromptContent(prompt: prompt) + .reauthContent { prompt in + received.capture(prompt) + } + ) + defer { window.isHidden = true } + + guard case let .emailLink(state) = received.value else { + Issue.record("Expected the email link prompt, got \(String(describing: received.value))") + return + } + #expect(state.email == "test@example.com") + #expect(state.linkSent) + } + + @Test("The nearest reauthContent wins") + func innermostContentWins() { + let outer = Recorder() + let inner = Recorder() + let window = host( + ReauthPromptContent(prompt: prompt) + .reauthContent { prompt in + inner.capture(prompt) + } + .reauthContent { prompt in + outer.capture(prompt) + } + ) + defer { window.isHidden = true } + + #expect(inner.value != nil) + #expect(outer.value == nil) + } +} diff --git a/FirebaseSwiftUI/README.md b/FirebaseSwiftUI/README.md index 4e879a15073..1f069911730 100644 --- a/FirebaseSwiftUI/README.md +++ b/FirebaseSwiftUI/README.md @@ -1705,6 +1705,8 @@ Thrown by sensitive operations when Firebase requires recent authentication. Eac - **`phoneReauthenticationRequired(context: PhoneReauthContext)`**: Phone provider. Context contains `phoneNumber` and `displayMessage`. Handle SMS verification, then call `reauthenticate(with:)`. +- **`multiFactorReauthenticationRequired(MFARequired)`**: Thrown by `reauthenticate(with:)` and `reauthenticate(context:)` when the user has enrolled second factors. Complete it as for sign-in MFA resolution: `resolveSmsChallenge(hintIndex:)` for SMS hints, then `resolveSignIn(code:hintIndex:verificationId:)`. Default views handle this inside the reauthentication sheet. + --- ### Best Practices diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift new file mode 100644 index 00000000000..e88aafeeb5d --- /dev/null +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/EmailRouteTests.swift @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +@testable import FirebaseAuthSwiftUI +import Testing + +@MainActor +struct EmailRouteTests { + private func makeService() -> AuthService { + configureFirebaseIfNeeded() + return AuthService() + } + + private let emailOption = LegacySignInOption(id: EmailAuthProviderID, displayName: "Email") + + @Test + func removeEmailRoutesKeepsOtherRoutes() { + let navigator = Navigator() + navigator.push(.signIn) + navigator.push(.signUp) + navigator.push(.passwordRecovery) + + navigator.removeEmailRoutes() + + #expect(navigator.routes == [.passwordRecovery]) + } + + @Test + func legacyEmailRecoveryReturnsToPushedSignIn() { + let service = makeService() + service.navigator.push(.signIn) + service.navigator.push(.signUp) + + service.startLegacyRecovery(option: emailOption, email: "user@example.com") + + #expect(service.navigator.routes == [.signIn]) + #expect(service.suggestedEmailAddress == "user@example.com") + } + + @Test + func legacyEmailRecoveryReturnsToFirstScreenWithoutEmailRoute() { + let service = makeService() + service.navigator.push(.enterPhoneNumber) + + service.startLegacyRecovery(option: emailOption, email: "user@example.com") + + #expect(service.navigator.routes.isEmpty) + } + + @Test + func signOutClearsNavigation() async throws { + let service = makeService() + service.navigator.push(.signIn) + service.navigator.push(.signUp) + + try await service.signOut() + + #expect(service.navigator.routes.isEmpty) + } +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift index 863c1045890..0b6f69eb34d 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/FirebaseSwiftUIExampleTests.swift @@ -254,4 +254,40 @@ struct FirebaseSwiftUIExampleTests { #expect(service.currentUser != nil) #expect(service.authView == nil) } + + @Test + @MainActor + func reauthenticateMFAUserRequiresSecondFactor() async throws { + let service = try await prepareFreshAuthService() + let email = createEmail() + let phoneNumber = "+1555123\(Int.random(in: 1000 ... 9999))" + try await service.createUser(email: email, password: kPassword) + try await waitForStateChange { service.currentUser != nil } + try await enrollSMSSecondFactor(email: email, password: kPassword, phoneNumber: phoneNumber) + + let credential = EmailAuthProvider.credential(withEmail: email, password: kPassword) + var mfaRequired: MFARequired? + do { + try await service.reauthenticate(with: credential) + } catch let AuthServiceError.multiFactorReauthenticationRequired(required) { + mfaRequired = required + } + + let hints = try #require(mfaRequired).hints + #expect(hints.count == 1) + guard case let .phone(_, _, hintPhoneNumber) = hints.first else { + Issue.record("Expected a phone hint, got \(hints)") + return + } + #expect(hintPhoneNumber?.hasSuffix(String(phoneNumber.suffix(4))) == true) + + // The resolver is kept so the second factor is resolved as for sign-in + let verificationId = try await service.resolveSmsChallenge(hintIndex: 0) + try await service.resolveSignIn( + code: fetchLatestSMSCode(for: phoneNumber), + hintIndex: 0, + verificationId: verificationId + ) + #expect(service.currentUser?.email?.caseInsensitiveCompare(email) == .orderedSame) + } } diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift index 120016c43d8..0cd0a14f97f 100644 --- a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TestHarness.swift @@ -174,3 +174,133 @@ func fetchEmailSignInLinkFromEmulator(email: String, userInfo: [NSLocalizedDescriptionKey: "No EMAIL_SIGNIN OOB link found for \(email)"] ) } + +// MARK: - MFA + +@MainActor +@discardableResult +private func postToAuthEmulator(_ path: String, + body: [String: Any], + asOwner: Bool = false, + emulatorHost: String = "127.0.0.1:9099") async throws + -> [String: Any] { + var request = URLRequest(url: URL(string: "http://\(emulatorHost)/\(path)")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if asOwner { + request.setValue("Bearer owner", forHTTPHeaderField: "Authorization") + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NSError( + domain: "EmulatorError", + code: 20, + userInfo: [ + NSLocalizedDescriptionKey: "\(path) failed: \(String(data: data, encoding: .utf8) ?? "")", + ] + ) + } + return json +} + +@MainActor +func fetchLatestSMSCode(for phoneNumber: String, + projectID: String = "flutterfire-e2e-tests", + emulatorHost: String = "127.0.0.1:9099") async throws -> String { + let url = + URL(string: "http://\(emulatorHost)/emulator/v1/projects/\(projectID)/verificationCodes")! + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw NSError( + domain: "EmulatorError", + code: 25, + userInfo: [NSLocalizedDescriptionKey: "Failed to fetch SMS verification codes"] + ) + } + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let codes = json?["verificationCodes"] as? [[String: Any]] ?? [] + // Codes sent for MFA resolution carry a masked number, e.g. "+*******4567" + let matches: ([String: Any]) -> Bool = { entry in + guard let number = entry["phoneNumber"] as? String else { return false } + return number == phoneNumber + || (number.contains("*") && number.suffix(4) == phoneNumber.suffix(4)) + } + guard let code = codes.last(where: matches)?["code"] as? String else { + throw NSError( + domain: "EmulatorError", + code: 21, + userInfo: [NSLocalizedDescriptionKey: "No SMS code found for \(phoneNumber)"] + ) + } + return code +} + +/// Verifies the user's email and enrolls an SMS second factor via the emulator REST API +@MainActor +func enrollSMSSecondFactor(email: String, + password: String, + phoneNumber: String, + projectID: String = "flutterfire-e2e-tests") async throws { + let signInPath = + "identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" + let signInBody: [String: Any] = [ + "email": email, + "password": password, + "returnSecureToken": true, + ] + guard let localId = try await postToAuthEmulator(signInPath, body: signInBody)["localId"] + as? String else { + throw NSError( + domain: "EmulatorError", + code: 23, + userInfo: [NSLocalizedDescriptionKey: "signInWithPassword returned no localId"] + ) + } + try await postToAuthEmulator( + "identitytoolkit.googleapis.com/v1/projects/\(projectID)/accounts:update", + body: ["localId": localId, "emailVerified": true], + asOwner: true + ) + guard let idToken = try await postToAuthEmulator(signInPath, body: signInBody)["idToken"] + as? String else { + throw NSError( + domain: "EmulatorError", + code: 24, + userInfo: [NSLocalizedDescriptionKey: "signInWithPassword returned no idToken"] + ) + } + + let start = try await postToAuthEmulator( + "identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start?key=fake-api-key", + body: [ + "idToken": idToken, + "phoneEnrollmentInfo": [ + "phoneNumber": phoneNumber, + "recaptchaToken": "fake-recaptcha-token", + ], + ] + ) + guard let sessionInfo = (start["phoneSessionInfo"] as? [String: Any])?["sessionInfo"] + as? String else { + throw NSError( + domain: "EmulatorError", + code: 22, + userInfo: [NSLocalizedDescriptionKey: "mfaEnrollment:start returned no sessionInfo"] + ) + } + + try await postToAuthEmulator( + "identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize?key=fake-api-key", + body: [ + "idToken": idToken, + "phoneVerificationInfo": [ + "sessionInfo": sessionInfo, + "code": await fetchLatestSMSCode(for: phoneNumber), + ], + "displayName": "Test Phone", + ] + ) +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift index 722c4b46cc6..0ad9b348098 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift @@ -143,6 +143,31 @@ struct ContentView: View { } } .tint(Color(.label)) + NavigationLink { + FullCustomizationExample() + .navigationTitle("Full customization") + .navigationBarTitleDisplayMode(.inline) + } label: { + VStack(alignment: .leading, spacing: 16) { + Text("Full customization example") + .font(.headline) + .fontWeight(.bold) + Text("How to draw every auth screen yourself with AuthPickerView") + Text( + "• Custom first screen, pushed screens and signed-in screen\n• Four switchable themes\n• Library still drives auth, MFA and navigation" + ) + .font(.caption) + .foregroundColor(.secondary) + } + .multilineTextAlignment(.leading) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color(UIColor.secondarySystemBackground)) + } + } + .tint(Color(.label)) } .padding() .navigationTitle("FirebaseUI Demo") diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/Contents.json b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/Contents.json new file mode 100644 index 00000000000..6e965652df6 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-mascot.imageset/Contents.json b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-mascot.imageset/Contents.json new file mode 100644 index 00000000000..2826852cb4d --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-mascot.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "doggo-mascot.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-mascot.imageset/doggo-mascot.svg b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-mascot.imageset/doggo-mascot.svg new file mode 100644 index 00000000000..03bcbddf78a --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-mascot.imageset/doggo-mascot.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-pattern.imageset/Contents.json b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-pattern.imageset/Contents.json new file mode 100644 index 00000000000..c568876d357 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-pattern.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "doggo-pattern.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-pattern.imageset/doggo-pattern.svg b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-pattern.imageset/doggo-pattern.svg new file mode 100644 index 00000000000..6270097dbd9 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/doggo-pattern.imageset/doggo-pattern.svg @@ -0,0 +1,600 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/google-g.imageset/Contents.json b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/google-g.imageset/Contents.json new file mode 100644 index 00000000000..28bd88c8b1f --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/google-g.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "google-g.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/google-g.imageset/google-g.svg b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/google-g.imageset/google-g.svg new file mode 100644 index 00000000000..821bc87e79d --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/google-g.imageset/google-g.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/grid-pattern.imageset/Contents.json b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/grid-pattern.imageset/Contents.json new file mode 100644 index 00000000000..a9869d91ac6 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/grid-pattern.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "grid-pattern.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/grid-pattern.imageset/grid-pattern.svg b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/grid-pattern.imageset/grid-pattern.svg new file mode 100644 index 00000000000..a92ee1512a3 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/grid-pattern.imageset/grid-pattern.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/spark-blob.imageset/Contents.json b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/spark-blob.imageset/Contents.json new file mode 100644 index 00000000000..d987d9c76c6 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/spark-blob.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "spark-blob@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/spark-blob.imageset/spark-blob@2x.png b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/spark-blob.imageset/spark-blob@2x.png new file mode 100644 index 00000000000..bc2b898406f Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Assets.xcassets/FullCustomization/spark-blob.imageset/spark-blob@2x.png differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoAccountScreens.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoAccountScreens.swift new file mode 100644 index 00000000000..16bcda16acb --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoAccountScreens.swift @@ -0,0 +1,420 @@ +// Copyright 2025 Google LLC +// +// 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. + +import CoreImage.CIFilterBuiltins +import FirebaseAuth +import FirebaseAuthSwiftUI +import SwiftUI + +// The Figma file has no frames for these screens; their copy and structure follow the +// FirebaseUI-Android full customization demo, restyled with the theme's Landing values. + +struct DemoSignedIn: View { + let state: SignedInContentState + + var body: some View { + DemoPage(title: "You're in") { + DemoBodyText("Signed in as \(state.user?.email ?? state.user?.phoneNumber ?? "you").") + // Email verification and password changes only apply to an account with an email. + let hasEmail = state.user?.email != nil + if hasEmail, !state.isEmailVerified { + DemoBodyText("Check your inbox to verify your email address.") + DemoButton( + title: "Verify email", + kind: .secondary, + identifier: "verify-email-button", + action: state.verifyEmail + ) + } + DemoErrorText(message: state.errorMessage) + if hasEmail { + DemoButton( + title: "Change password", + identifier: "update-password-button", + action: state.updatePassword + ) + } + if state.isMFAEnabled { + DemoButton( + title: "Manage two-factor", + kind: .secondary, + identifier: "mfa-management-button", + action: state.goToMFAManagement + ) + } + DemoLink( + title: "Sign out", + alignment: .leading, + identifier: "sign-out-button", + action: state.signOut + ) + DemoLink( + title: "Delete account", + alignment: .leading, + identifier: "delete-account-button", + action: state.deleteAccount + ) + } + .disabled(state.isLoading) + } +} + +struct DemoMFAManagement: View { + var body: some View { + MFAManagementScreen { state in + DemoPage(title: "Two-factor") { + if state.enrolledFactors.isEmpty { + DemoBodyText("Add a second step to sign-in, so a password on its own isn't enough.") + } else { + DemoBodyText("Already on this account") + ForEach(state.enrolledFactors, id: \.uid) { factor in + DemoFactorRow(factor: factor) { + state.unenroll(factor.uid) + } + .disabled(state.isLoading) + } + } + DemoErrorText(message: state.errorMessage) + DemoButton( + title: state.enrolledFactors.isEmpty ? "Set up two-factor" : "Add another method", + isLoading: state.isLoading, + identifier: "add-factor-button", + action: state.goToEnrollment + ) + } + } + } +} + +private struct DemoFactorRow: View { + let factor: MultiFactorInfo + let remove: () -> Void + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + let theme = themeID.theme + let shape = RoundedRectangle(cornerRadius: theme.fieldCornerRadius) + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text(factor.displayName ?? "Second step") + .demoText(theme.fieldText) + Text(factor.factorID == PhoneMultiFactorID + ? (factor as? PhoneMultiFactorInfo)?.phoneNumber ?? "Text message" + : "Authenticator app") + .font(theme.fieldText.font(relativeTo: .footnote)) + .foregroundStyle(theme.fieldPlaceholder) + } + Spacer() + Button("Remove", action: remove) + .font(theme.link.font()) + .foregroundStyle(theme.link.color) + .buttonStyle(.plain) + .opacity(isEnabled ? 1 : 0.5) + .accessibilityIdentifier("remove-factor-button") + } + .padding(.horizontal, 16) + .frame(minHeight: 56) + .background(theme.fieldFill, in: shape) + .overlay { shape.strokeBorder(theme.fieldStroke.color, lineWidth: theme.fieldStroke.width) } + } +} + +struct DemoMFAEnrolment: View { + var body: some View { + MFAEnrolmentScreen { DemoMFAEnrolmentContent(state: $0) } + } +} + +/// The enrolment steps, apart from `MFAEnrolmentScreen` so they can be previewed with a stub +/// state: the Auth emulator can't enrol TOTP. +struct DemoMFAEnrolmentContent: View { + let state: MFAEnrollmentContentState + + var body: some View { + switch state.step { + case .selectFactor: + DemoPage(title: "Secure your account") { + DemoBodyText("Add a second step to sign-in, so a password on its own isn't enough.") + ForEach(state.allowedFactors, id: \.self) { factor in + DemoChoiceRow( + title: factor == .sms ? "Text message" : "Authenticator app", + isSelected: state.selectedFactorType.wrappedValue == factor, + identifier: factor == .sms ? "sms-factor-button" : "totp-factor-button" + ) { + state.selectedFactorType.wrappedValue = factor + } + } + DemoErrorText(message: state.isMFAEnabled + ? state.errorMessage + : "Two-factor sign-in isn't enabled for this app.") + DemoButton( + title: "Continue", + isLoading: state.isLoading, + identifier: "start-enrollment-button", + action: state.startEnrollment + ) + .disabled(!state.isValid) + } + case .enterPhoneNumber: + DemoPage(title: "Add your number") { + DemoBodyText("We'll text a code to this number whenever you sign in.") + displayNameField(state) + DemoPhoneNumberField(phoneNumber: state.phoneNumber, country: state.selectedCountry) + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "send-code-button", + action: state.sendCode + ) + .disabled(!state.isValid) + } + case .enterVerificationCode: + DemoPage(title: "Confirm the code") { + DemoBodyText("We sent a code to \(state.sentPhoneNumber ?? "your phone").") + DemoCodeField(code: state.verificationCode, identifier: "verification-code-field") + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "enroll-button", + action: state.enroll + ) + .disabled(!state.isValid) + DemoLink( + title: "Resend code", + alignment: .leading, + identifier: "resend-code-button", + action: state.sendCode + ) + } + case .setUpTOTP: + DemoPage(title: "Scan to set up") { + DemoBodyText("Scan this with your authenticator app, or type the key in by hand.") + if let url = state.totpQRCodeURL { + DemoQRCode(url: url) + } + if let secret = state.totpSecret { + DemoLink( + title: state.didCopySecret ? "Copied" : secret, + alignment: .center, + identifier: "totp-secret", + action: state.copySecret + ) + } + displayNameField(state) + DemoCodeField(code: state.totpCode, identifier: "totp-code-field") + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "enroll-button", + action: state.enroll + ) + .disabled(!state.isValid) + } + } + } + + private func displayNameField(_ state: MFAEnrollmentContentState) -> some View { + DemoTextField( + icon: "person", + placeholder: "Name this method", + text: state.displayName, + identifier: "display-name-field" + ) + } +} + +#if DEBUG + extension MFAEnrollmentContentState { + /// The TOTP setup step with stub values and no-op actions. + static var totpSetUpPreview: MFAEnrollmentContentState { + MFAEnrollmentContentState( + step: .setUpTOTP, + selectedFactorType: .constant(.totp), + phoneNumber: .constant(""), + selectedCountry: .constant(.default), + verificationCode: .constant(""), + totpCode: .constant(""), + displayName: .constant(""), + startEnrollment: {}, + sendCode: {}, + enroll: {}, + copySecret: {}, + isMFAEnabled: true, + allowedFactors: [.sms, .totp], + sentPhoneNumber: nil, + totpSecret: "JBSWY3DPEHPK3PXP", + totpQRCodeURL: URL( + string: "otpauth://totp/FirebaseUI:rover@example.com?secret=JBSWY3DPEHPK3PXP&issuer=FirebaseUI" + ), + didCopySecret: false, + isValid: false, + isLoading: false, + errorMessage: nil + ) + } + } + + #Preview("TOTP setup") { + NavigationStack { + DemoMFAEnrolmentContent(state: .totpSetUpPreview) + } + } +#endif + +struct DemoMFAResolution: View { + let mfaRequired: MFARequired + + var body: some View { + MFAResolutionScreen(mfaRequired: mfaRequired) { state in + DemoPage(title: "One more step") { + if state.hints.count > 1 { + ForEach(Array(state.hints.enumerated()), id: \.offset) { index, hint in + DemoChoiceRow( + title: label(for: hint), + isSelected: state.selectedHintIndex.wrappedValue == index, + identifier: "hint-\(index)-button" + ) { + state.selectedHintIndex.wrappedValue = index + } + } + } + if let hint = selectedHint(state) { + switch hint { + case let .phone(_, _, phoneNumber): + if state.isCodeSent { + DemoBodyText("We sent a code to \(phoneNumber ?? "your phone").") + DemoCodeField(code: state.verificationCode, identifier: "verification-code-field") + } else { + DemoBodyText("We'll text a code to \(phoneNumber ?? "your phone").") + } + case .totp: + DemoBodyText("Enter the 6-digit code your authenticator app is showing right now.") + DemoCodeField(code: state.totpCode, identifier: "totp-code-field") + } + } + DemoErrorText(message: state.errorMessage) + if case .phone = selectedHint(state), !state.isCodeSent { + DemoButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "send-code-button", + action: state.sendCode + ) + } else { + DemoButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "resolve-button", + action: state.resolve + ) + .disabled(!state.isValid) + } + DemoLink( + title: "Cancel", + alignment: .leading, + identifier: "cancel-button", + action: state.cancel + ) + } + } + } + + private func selectedHint(_ state: MFAResolutionContentState) -> MFAHint? { + let index = state.selectedHintIndex.wrappedValue + return state.hints.indices.contains(index) ? state.hints[index] : nil + } + + private func label(for hint: MFAHint) -> String { + switch hint { + case let .phone(displayName, _, _): displayName ?? "Text message" + case let .totp(displayName, _): displayName ?? "Authenticator app" + } + } +} + +/// A choice between second factors, drawn in the text field's style. +private struct DemoChoiceRow: View { + let title: String + let isSelected: Bool + var identifier: String + let action: () -> Void + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + let theme = themeID.theme + let shape = RoundedRectangle(cornerRadius: theme.fieldCornerRadius) + Button(action: action) { + HStack { + Text(title) + .demoText(theme.fieldText) + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(theme.fieldIcon) + } + .padding(.horizontal, 16) + .frame(minHeight: 56) + .background(theme.fieldFill, in: shape) + .overlay { + shape.strokeBorder(theme.fieldStroke.color, lineWidth: theme.fieldStroke.width) + } + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier(identifier) + } +} + +/// A QR code for the TOTP setup URL, drawn with Core Image once per URL. +struct DemoQRCode: View { + let url: URL + + @State private var image: UIImage? + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .interpolation(.none) + .resizable() + } else { + Color.clear + } + } + .frame(width: 176, height: 176) + .padding(12) + .background(.white, in: RoundedRectangle(cornerRadius: 24)) + .frame(maxWidth: .infinity) + .accessibilityLabel("QR code for your authenticator app") + .accessibilityIdentifier("totp-qr-code") + .task(id: url) { + image = Self.makeImage(for: url) + } + } + + private static func makeImage(for url: URL) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(url.absoluteString.utf8) + guard let output = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)), + let cgImage = CIContext().createCGImage(output, from: output.extent) else { + return nil + } + return UIImage(cgImage: cgImage) + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoComponents.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoComponents.swift new file mode 100644 index 00000000000..87f30e59938 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoComponents.swift @@ -0,0 +1,374 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import SwiftUI + +/// The layout every demo screen shares, from the Figma "sign up" frame: a title, a form card and +/// an optional footer, over the theme's background art. Accessibility identifiers use the Figma +/// layer names. +struct DemoPage: View { + /// `showsThemeMenu` is off for screens in their own sheet, such as reauthentication. + init(title: String?, + showsThemeMenu: Bool = true, + @ViewBuilder card: @escaping () -> Card, + @ViewBuilder footer: @escaping () -> Footer = { EmptyView() }) { + self.title = title + self.showsThemeMenu = showsThemeMenu + self.card = card + self.footer = footer + } + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + private let title: String? + private let showsThemeMenu: Bool + private let card: () -> Card + private let footer: () -> Footer + + var body: some View { + let theme = themeID.theme + // The background is its own layer, so it fills the sheet whatever size the scroll view takes. + ZStack { + DemoThemeBackground(theme: theme) + ScrollView { + VStack(spacing: 0) { + if theme.id == .spark { + SparkHeadline() + // "headline" ends at y 135 and "inputs" starts at y 200. + .padding(.bottom, title == nil ? 65 : 24) + } + VStack(alignment: .leading, spacing: theme.titleSpacing) { + if let title { + Text(title) + .font(theme.title.font(relativeTo: .title)) + .foregroundStyle(theme.title.color) + // Long titles wrap before the 83-point mascot and its 8.6-point inset. + .padding(.trailing, theme.id == .doggo ? 92 : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("title") + } + DemoCard(theme: theme, content: card) + } + .overlay(alignment: .topTrailing) { + if theme.id == .doggo, title != nil { + // "doggo" ends 8.6 points inside the form's trailing edge (x 372.4 against 381) and + // starts 10.7 points above the title (y 163.3 against 174). + Image("FullCustomization/doggo-mascot") + .offset(x: -8.6, y: -10.7) + .accessibilityHidden(true) + .accessibilityIdentifier("doggo") + } + } + .demoContentWidth() + footer() + // With the switch row's 12-point inset, its text sits 24 points below the form. + .padding(.top, 12) + } + // Full width, so the whole sheet scrolls while the form stays centred. + .frame(maxWidth: .infinity) + .padding(.top, 16) + .padding(.bottom, 24) + } + .scrollBounceBehavior(.basedOnSize) + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsThemeMenu { + ToolbarItem(placement: .topBarLeading) { + DemoThemeMenu() + } + } + } + } +} + +/// Figma "headline" (20:5927), Coffee Spark only. +private struct SparkHeadline: View { + var body: some View { + HStack(spacing: 10) { + // Material Symbols "coffee". + Image(systemName: "cup.and.saucer") + .font(.system(size: 20, weight: .medium)) + Text("Coffee Spark") + .font(.custom("JetBrainsMono-Medium", size: 20, relativeTo: .title3)) + } + .foregroundStyle(Color(demoHex: 0x1C1B1B)) + .padding(.horizontal, 16) + .frame(height: 48) + .background(.white, in: RoundedRectangle(cornerRadius: 24)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("headline") + } +} + +/// Figma "inputs": a 16-point stack, drawn as a card with a hard offset shadow in Doggo. +private struct DemoCard: View { + let theme: DemoTheme + let content: () -> Content + + var body: some View { + let shape = RoundedRectangle(cornerRadius: theme.cardCornerRadius) + VStack(alignment: .leading, spacing: 16, content: content) + .padding(theme.cardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + if let fill = theme.cardFill { + shape.fill(fill) + } + } + .overlay { + if let stroke = theme.cardStroke { + shape.strokeBorder(stroke.color, lineWidth: stroke.width) + } + } + .hardShadow(theme.cardShadow, shape: shape) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("inputs") + } +} + +extension View { + /// The width every demo screen's content takes: inset 32 points from the sheet's edges, and + /// no wider than 480 points so it doesn't stretch on iPad. Narrower than the Figma frames' + /// 49-point inset, which left too little room on phones. + func demoContentWidth() -> some View { + frame(maxWidth: 480) + .padding(.horizontal, 32) + } + + /// A Figma drop shadow with zero blur: an offset copy of the shape drawn behind the view. + @ViewBuilder + func hardShadow(_ shadow: DemoTheme.Shadow?, shape: some Shape) -> some View { + if let shadow { + background { + shape.fill(shadow.color).offset(x: shadow.x, y: shadow.y) + } + } else { + self + } + } + + func demoText(_ token: DemoTheme.TextToken, + relativeTo style: Font.TextStyle = .body) -> some View { + font(token.font(relativeTo: style)) + .foregroundStyle(token.color) + .underline(token.underline) + } +} + +/// Figma "text-field": 56 points tall with a 48-point leading icon slot. `icon` is an SF Symbol +/// standing in for the Material Symbols glyph named in Figma. +struct DemoTextField: View { + var icon: String? + let placeholder: String + @Binding var text: String + var isSecure = false + var keyboard: UIKeyboardType = .default + var contentType: UITextContentType? + var identifier: String + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + @State private var isRevealed = false + + var body: some View { + let theme = themeID.theme + let shape = RoundedRectangle(cornerRadius: theme.fieldCornerRadius) + HStack(spacing: 0) { + if let icon { + Image(systemName: icon) + .font(.system(size: 20, weight: theme.fieldIconWeight)) + .foregroundStyle(theme.fieldIcon) + .frame(width: 48, height: 48) + } + Group { + if isSecure, !isRevealed { + SecureField(text: $text) { placeholderText(theme) } + } else { + TextField(text: $text) { placeholderText(theme) } + } + } + .font(theme.fieldText.font()) + .foregroundStyle(theme.fieldText.color) + .tint(theme.fieldText.color) + .keyboardType(keyboard) + .textContentType(contentType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(.leading, icon == nil ? 16 : 0) + .accessibilityIdentifier(identifier) + if isSecure { + Button { + isRevealed.toggle() + } label: { + Image(systemName: isRevealed ? "eye" : "eye.slash") + .foregroundStyle(theme.fieldTrailingIcon) + .frame(width: 48, height: 48) + } + .accessibilityLabel(isRevealed ? "Hide password" : "Show password") + .accessibilityIdentifier("trailing-icon") + } + } + .padding(.vertical, 4) + .padding(.trailing, isSecure ? 0 : 16) + .frame(minHeight: 56) + .background(theme.fieldFill, in: shape) + .overlay { + shape.strokeBorder(theme.fieldStroke.color, lineWidth: theme.fieldStroke.width) + } + } + + private func placeholderText(_ theme: DemoTheme) -> Text { + Text(placeholder).foregroundStyle(theme.fieldPlaceholder) + } +} + +/// Figma "button": 64 points tall and as wide as the form. +struct DemoButton: View { + enum Kind { + case primary + case secondary + } + + let title: String + var kind: Kind = .primary + var isLoading = false + var identifier: String + let action: () -> Void + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + let theme = themeID.theme + let colors = kind == .primary ? theme.primaryButton : theme.secondaryButton + let shape = RoundedRectangle(cornerRadius: theme.buttonCornerRadius) + Button(action: action) { + ZStack { + Text(title) + .font(theme.buttonFont.font(relativeTo: .title3)) + .opacity(isLoading ? 0 : 1) + if isLoading { + ProgressView().tint(colors.label) + } + } + .foregroundStyle(colors.label) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, minHeight: theme.buttonHeight) + .background(colors.fill, in: shape) + .overlay { + if let stroke = theme.buttonStroke { + shape.strokeBorder(stroke.color, lineWidth: stroke.width) + } + } + .hardShadow(theme.buttonShadow, shape: shape) + // Composited first, so the shadow doesn't show through the faded fill. + .compositingGroup() + .opacity(isEnabled ? 1 : 0.5) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +/// Figma "Forgot password?": a text link in the theme's link style. +struct DemoLink: View { + let title: String + var alignment: Alignment = .trailing + var identifier: String + let action: () -> Void + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + @Environment(\.isEnabled) private var isEnabled + + private var textAlignment: TextAlignment { + switch alignment { + case .center: .center + case .trailing: .trailing + default: .leading + } + } + + var body: some View { + Button(action: action) { + Text(title) + .demoText(themeID.theme.link) + .multilineTextAlignment(textAlignment) + .opacity(isEnabled ? 1 : 0.5) + // A taller tap area than the text, without changing the layout. + .padding(.vertical, 8) + .contentShape(Rectangle()) + .padding(.vertical, -8) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: alignment) + .accessibilityIdentifier(identifier) + } +} + +/// Supporting copy on the screens Figma doesn't cover, in the "Other sign in methods" style. +struct DemoBodyText: View { + let text: String + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .demoText(themeID.theme.body) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// A slot's `errorMessage`, set in the library's typography with `.authFont(_:weight:)`. +struct DemoErrorText: View { + let message: String? + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + if let message { + Text(message) + .authFont(.footnote) + .foregroundStyle(themeID.theme.errorColor) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("error-message") + } + } +} + +/// Switches the demo's theme live from any screen, like the web demo's switcher. +struct DemoThemeMenu: View { + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + Menu { + Picker("Theme", selection: $themeID) { + ForEach(DemoThemeID.allCases) { theme in + Text(theme.label).tag(theme) + } + } + } label: { + Image(systemName: "paintpalette") + .foregroundStyle(Color(UIColor.label)) + } + .accessibilityLabel("Theme") + .accessibilityIdentifier("theme-switcher") + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoEmailAndPhoneScreens.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoEmailAndPhoneScreens.swift new file mode 100644 index 00000000000..cde1bfaed9a --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoEmailAndPhoneScreens.swift @@ -0,0 +1,207 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import SwiftUI + +// The Figma file has no frames for these screens; their copy and structure follow the +// FirebaseUI-Android full customization demo, restyled with the theme's Landing values. + +struct DemoPasswordRecovery: View { + var body: some View { + PasswordRecoveryScreen { state in + if state.didSend { + DemoPage(title: "Check your inbox") { + DemoBodyText("Reset link sent! Follow the link we sent to \(state.sentEmail).") + DemoButton( + title: "Back to login", + identifier: "back-to-login-button", + action: state.dismissSuccess + ) + } + } else { + DemoPage(title: "Reset password") { + DemoBodyText("Enter your email address and we'll send you a link to reset it.") + DemoTextField( + icon: "at", + placeholder: "Email address", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Send reset link", + isLoading: state.isLoading, + identifier: "send-reset-link-button", + action: state.sendResetLink + ) + .disabled(!state.isValid) + } + } + } + } +} + +struct DemoEmailLink: View { + var body: some View { + EmailLinkScreen { state in + if state.didSend { + DemoPage(title: "Check your inbox") { + DemoBodyText( + "Login link sent! Open it on this device to sign in as \(state.email.wrappedValue)." + ) + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Use a different email", + kind: .secondary, + isLoading: state.isLoading, + identifier: "different-email-button", + action: state.dismissAlert + ) + } + } else { + DemoPage(title: "Email link sign-in") { + DemoBodyText("We'll email you a link that signs you in, no password needed.") + DemoTextField( + icon: "at", + placeholder: "Email address", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Send login link", + isLoading: state.isLoading, + identifier: "send-login-link-button", + action: state.sendSignInLink + ) + .disabled(!state.isValid) + } + } + } + } +} + +struct DemoEnterPhoneNumber: View { + var body: some View { + EnterPhoneNumberScreen { state in + DemoPage(title: "Login by phone number") { + DemoPhoneNumberField(phoneNumber: state.phoneNumber, country: state.selectedCountry) + DemoBodyText( + "By signing in with phone number, an SMS may be sent. Message & data rates may apply." + ) + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "send-code-button", + action: state.sendCode + ) + .disabled(!state.isValid) + } + } + } +} + +struct DemoEnterVerificationCode: View { + let verificationID: String + let fullPhoneNumber: String + + var body: some View { + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { state in + DemoPage(title: "Enter your code") { + DemoBodyText("We sent a code to \(state.fullPhoneNumber).") + DemoCodeField(code: state.verificationCode, identifier: "verification-code-field") + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "verify-button", + action: state.verify + ) + .disabled(!state.isValid) + DemoLink( + title: state.resendCountdown > 0 + ? "Resend code in \(state.resendCountdown)s" + : "Resend code", + alignment: .leading, + identifier: "resend-code-button", + action: state.resendCode + ) + .disabled(state.resendCountdown > 0 || state.isLoading) + DemoLink( + title: "Use a different number", + alignment: .leading, + identifier: "change-number-button", + action: state.changeNumber + ) + } + } + } +} + +/// The library's `CountrySelector` inside a demo text field's frame, tinted with the field text. +struct DemoPhoneNumberField: View { + let phoneNumber: Binding + let country: Binding + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + let theme = themeID.theme + let shape = RoundedRectangle(cornerRadius: theme.fieldCornerRadius) + HStack(spacing: 8) { + CountrySelector(selectedCountry: country) + .tint(theme.fieldText.color) + .font(theme.fieldText.font()) + .accessibilityIdentifier("country-selector") + TextField(text: phoneNumber) { + Text("Phone number").foregroundStyle(theme.fieldPlaceholder) + } + .font(theme.fieldText.font()) + .foregroundStyle(theme.fieldText.color) + .keyboardType(.phonePad) + .textContentType(.telephoneNumber) + .accessibilityIdentifier("phone-number-field") + } + .padding(.horizontal, 16) + .frame(minHeight: 56) + .background(theme.fieldFill, in: shape) + .overlay { shape.strokeBorder(theme.fieldStroke.color, lineWidth: theme.fieldStroke.width) } + } +} + +/// A one-time code in a demo text field. +struct DemoCodeField: View { + let code: Binding + var identifier: String + + var body: some View { + DemoTextField( + icon: "number", + placeholder: "6-digit code", + text: code, + keyboard: .numberPad, + contentType: .oneTimeCode, + identifier: identifier + ) + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoLanding.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoLanding.swift new file mode 100644 index 00000000000..718eb64eb6f --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoLanding.swift @@ -0,0 +1,342 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import FirebaseOAuthSwiftUI +import SwiftUI + +/// The whole signed-out first screen, from the Figma Landing frames. `EmailAuthScreen` drives the +/// email form, and the provider buttons start sign-in through `AuthMethodPickerState`, so MFA, +/// account conflicts and errors are routed the same as with the stock screen. +struct DemoLanding: View { + let pickerState: AuthMethodPickerState + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + EmailAuthScreen { state in + DemoPage(title: title(for: state.flow)) { + emailForm(state) + } footer: { + DemoOtherSignInMethods( + pickerState: pickerState, + flow: state.flow, + switchFlow: state.switchFlow + ) + } + } + } + + private func title(for flow: AuthenticationFlow) -> String? { + guard themeID.theme.showsTitle else { return nil } + return flow == .signIn ? "Login" : "Sign up" + } + + @ViewBuilder + private func emailForm(_ state: EmailAuthContentState) -> some View { + DemoTextField( + icon: "at", + placeholder: "Email address", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + DemoTextField( + icon: "ellipsis.rectangle", + placeholder: "Password", + text: state.password, + isSecure: true, + contentType: state.flow == .signIn ? .password : .newPassword, + identifier: "password-field" + ) + if state.flow == .signUp { + DemoTextField( + icon: "ellipsis.rectangle", + placeholder: "Confirm password", + text: state.confirmPassword, + isSecure: true, + contentType: .newPassword, + identifier: "confirm-password-field" + ) + } + DemoErrorText(message: state.errorMessage) + if state.flow == .signIn { + DemoLink( + title: "Forgot password?", + identifier: "forgot-password", + action: state.goToPasswordRecovery + ) + } + DemoButton( + title: state.flow == .signIn ? "Login" : "Create account", + isLoading: state.isLoading, + identifier: "sign-in-button", + action: state.flow == .signIn ? state.signIn : state.signUp + ) + .disabled(!state.isValid) + if pickerState.isEmailLinkEnabled { + DemoButton( + title: "Email link sign-in", + kind: .secondary, + identifier: "email-link-button", + action: state.goToEmailLink + ) + } + } +} + +/// Figma "Content": the other sign-in methods, the flow switch and the terms. Google and phone +/// get the buttons drawn in Figma; any other registered provider is listed in a sheet. +private struct DemoOtherSignInMethods: View { + let pickerState: AuthMethodPickerState + let flow: AuthenticationFlow + let switchFlow: () -> Void + + @Environment(AuthService.self) private var authService + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + @State private var isShowingOtherProviders = false + @State private var pendingProvider: AuthProviderUI? + + private var google: AuthProviderUI? { + pickerState.providers.first { $0.id == "google.com" } + } + + private var phone: AuthProviderUI? { + pickerState.providers.first { $0.id == "phone" } + } + + private var otherProviders: [AuthProviderUI] { + pickerState.providers.filter { $0.id != "google.com" && $0.id != "phone" } + } + + var body: some View { + let theme = themeID.theme + VStack(spacing: 0) { + // Not where Figma has it: the sign-in / sign-up switch belongs to the email form, so it + // sits right below the form, above the other sign-in methods. + Button(action: switchFlow) { + flowSwitchText(theme) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("login") + + VStack(spacing: 12) { + // Like the Figma text layer, the label spans the frame's padding so it doesn't wrap. + Text("Other sign in methods") + .demoText(theme.otherLabel) + .lineLimit(1) + .padding(.horizontal, -theme.otherPadding.leading) + .accessibilityIdentifier("other-sign-in-methods") + if let google { + DemoProviderButton( + title: "Continue with Google", + fill: theme.pillFill, + label: theme.googleLabel, + stroke: theme.pillStroke, + cornerRadius: theme.pillCornerRadius, + identifier: "google-button" + ) { + Image("FullCustomization/google-g") + } action: { + pickerState.select(google) + } + } + if let phone { + DemoProviderButton( + title: "Sign in with phone", + fill: theme.phoneProvider.fill, + label: theme.phoneProvider.label, + cornerRadius: theme.phoneProvider.cornerRadius, + identifier: "phone-button" + ) { + // Material Symbols "phone". + Image(systemName: "phone") + .font(.system(size: 18, weight: .medium)) + } action: { + pickerState.select(phone) + } + } + if !otherProviders.isEmpty { + // Not in Figma: the remaining providers, behind a pill styled like Google's. + DemoProviderButton( + title: "More sign-in options", + fill: theme.pillFill, + label: theme.googleLabel, + stroke: theme.pillStroke, + cornerRadius: theme.pillCornerRadius, + identifier: "other-providers-button" + ) { + Image(systemName: "ellipsis.circle") + .font(.system(size: 18, weight: .medium)) + } action: { + isShowingOtherProviders = true + } + } + } + .padding(theme.otherPadding) + .overlay(alignment: .top) { + // A 4-point top stroke centred on the frame edge, so the inner 2 points show. + Rectangle() + .fill(theme.dividerColor) + .frame(height: 2) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("other") + // With the switch row's 12-point inset, the divider sits 24 points below the switch text, + // the same as the gap above it. + .padding(.top, 12) + + termsText(theme) + .padding(.top, 24) + } + .demoContentWidth() + // Sign-in starts once the sheet is gone, so a provider that presents its own screen + // (such as an OAuth web view) isn't presented from a sheet that is animating away. + .sheet(isPresented: $isShowingOtherProviders, onDismiss: { + if let provider = pendingProvider { + pendingProvider = nil + pickerState.select(provider) + } + }) { + DemoOtherProvidersSheet(providers: otherProviders) { provider in + pendingProvider = provider + isShowingOtherProviders = false + } + .presentationDetents([.medium, .large]) + } + } + + private func flowSwitchText(_ theme: DemoTheme) -> Text { + let prompt = flow == .signIn ? "Don't have an account? " : "Already have an account? " + let action = flow == .signIn ? "Sign up" : "Login" + return Text(prompt) + .font(theme.footerText.font()) + .foregroundColor(theme.footerText.color) + + Text(action) + .font(theme.footerAccent.font()) + .foregroundColor(theme.footerAccent.color) + .underline(theme.footerAccent.underline) + } + + /// The library's `PrivacyTOCsView` is internal, so a whole custom first screen draws its own + /// terms copy from the configured URLs. + @ViewBuilder + private func termsText(_ theme: DemoTheme) -> some View { + let configuration = authService.configuration + if let tosUrl = configuration.tosUrl, let privacyPolicyUrl = configuration.privacyPolicyUrl, + let terms = try? AttributedString(markdown: """ + By continuing, you agree to the [Terms of Service](\(tosUrl.absoluteString)) \ + and [Privacy Policy](\(privacyPolicyUrl.absoluteString)). + """) { + Text(terms) + .font(theme.footerText.font(relativeTo: .footnote)) + .foregroundStyle(theme.footerText.color) + .tint(theme.footerAccent.color) + .multilineTextAlignment(.center) + .accessibilityIdentifier("terms") + } + } +} + +/// Figma "android_light_rd_SI": a 52-point pill with a 20-point icon and a label. +private struct DemoProviderButton: View { + let title: String + let fill: Color + let label: DemoTheme.TextToken + var stroke: Color? + let cornerRadius: CGFloat + var identifier: String + @ViewBuilder let icon: () -> Icon + let action: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius) + Button(action: action) { + HStack(spacing: 12) { + icon() + .frame(width: 20, height: 20) + Text(title) + .font(label.font(relativeTo: .subheadline)) + .tracking(0.1) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(label.color) + .padding(16) + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + .background(fill, in: shape) + .overlay { + if let stroke { + shape.strokeBorder(stroke, lineWidth: 1) + } + } + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +/// The registered providers the Figma frames don't draw, as the library's own brand buttons. +/// `AuthProviderButton` takes the demo's action, so sign-in still goes through +/// `AuthMethodPickerState.select` once the sheet has closed. +struct DemoOtherProvidersSheet: View { + let providers: [AuthProviderUI] + let select: (AuthProviderUI) -> Void + + @Environment(AuthService.self) private var authService + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + let theme = themeID.theme + ScrollView { + VStack(spacing: 16) { + Text("Other sign in methods") + .demoText(theme.otherLabel) + .padding(.bottom, 4) + ForEach(providers, id: \.id) { provider in + let button = stockButton(for: provider) + AuthProviderButton( + label: button.label, + style: button.style, + accessibilityId: "\(provider.id)-button" + ) { + select(provider) + } + } + } + .demoContentWidth() + .padding(.vertical, 32) + .frame(maxWidth: .infinity) + } + .background { DemoThemeBackground(theme: theme) } + } + + /// The label and style the provider's stock button uses. + private func stockButton(for provider: AuthProviderUI) -> (label: String, style: ProviderStyle) { + if let oauth = provider.provider as? OAuthProviderSwift { + return (oauth.buttonLabel, ProviderStyle( + icon: oauth.buttonIcon, + backgroundColor: oauth.buttonBackgroundColor, + contentColor: oauth.buttonForegroundColor + )) + } + if provider.id == "apple.com" { + return (authService.string.appleLoginButtonLabel, .apple) + } + return ("Sign in with \(provider.displayName)", .empty) + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoReauthScreens.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoReauthScreens.swift new file mode 100644 index 00000000000..28971652b49 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoReauthScreens.swift @@ -0,0 +1,244 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import SwiftUI + +// The reauthentication prompts, given to `.reauthContent`. The library still presents them as +// sheets when a sensitive action (deleting the account, changing the password, managing +// two-factor) needs a recent sign-in, and still verifies the user and reports errors. Neither the +// Figma file nor the Android demo draws them, so they reuse each theme's own screen pieces. + +/// The reauthentication prompt for the selected theme. +struct ThemedReauth: View { + let prompt: ReauthPrompt + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + if themeID == .professional { + ProReauth(prompt: prompt) + } else { + DemoReauth(prompt: prompt) + } + } +} + +/// The Figma themes' reauthentication prompt, in the shared layout. +private struct DemoReauth: View { + let prompt: ReauthPrompt + + var body: some View { + switch prompt { + case let .password(state): + DemoPage(title: "Confirm it's you", showsThemeMenu: false) { + DemoBodyText("Enter the password for \(state.email) to continue.") + DemoTextField( + icon: "ellipsis.rectangle", + placeholder: "Password", + text: state.password, + isSecure: true, + contentType: .password, + identifier: "reauth-password-field" + ) + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Continue", + isLoading: state.isLoading, + identifier: "reauth-verify-button", + action: state.verify + ) + .disabled(!state.isValid) + DemoLink( + title: "Cancel", + alignment: .leading, + identifier: "reauth-cancel-button", + action: state.cancel + ) + } + case let .phone(state): + DemoPage(title: "Confirm it's you", showsThemeMenu: false) { + DemoBodyText(state.codeSent + ? "We sent a code to \(state.phoneNumber)." + : "We'll text a code to \(state.phoneNumber) to confirm it's you.") + if state.codeSent { + DemoCodeField(code: state.verificationCode, identifier: "reauth-code-field") + } + DemoErrorText(message: state.errorMessage) + if state.codeSent { + DemoButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "reauth-verify-button", + action: state.verify + ) + .disabled(!state.isValid) + DemoLink( + title: "Resend code", + alignment: .leading, + identifier: "reauth-resend-button", + action: state.sendCode + ) + .disabled(state.isLoading) + } else { + DemoButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "reauth-send-code-button", + action: state.sendCode + ) + } + DemoLink( + title: "Cancel", + alignment: .leading, + identifier: "reauth-cancel-button", + action: state.cancel + ) + } + case let .emailLink(state): + DemoPage(title: "Check your inbox", showsThemeMenu: false) { + DemoBodyText(state.linkSent + ? "We sent a sign-in link to \(state.email). Open it on this device to continue." + : "Sending a sign-in link to \(state.email).") + DemoErrorText(message: state.errorMessage) + DemoButton( + title: "Resend link", + kind: .secondary, + isLoading: state.isLoading, + identifier: "reauth-resend-button", + action: state.resendLink + ) + DemoLink( + title: "Cancel", + alignment: .leading, + identifier: "reauth-cancel-button", + action: state.cancel + ) + } + } + } +} + +/// The Professional reauthentication prompt, in its own layout. +private struct ProReauth: View { + let prompt: ReauthPrompt + + var body: some View { + switch prompt { + case let .password(state): + ProPage(showsThemeMenu: false) { + ProHeader( + symbol: "lock", + title: "Confirm it's you", + subtitle: "For your security, enter the password for \(state.email) to continue." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + ProField( + label: "Password", + placeholder: "Enter your password", + text: state.password, + isSecure: true, + contentType: .password, + identifier: "reauth-password-field" + ) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Continue", + isLoading: state.isLoading, + identifier: "reauth-verify-button", + action: state.verify + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + ProTextButton(title: "Cancel", identifier: "reauth-cancel-button", action: state.cancel) + .padding(.top, 4) + } + case let .phone(state): + ProPage(showsThemeMenu: false) { + ProHeader( + symbol: "phone", + title: "Confirm it's you", + subtitle: state.codeSent + ? "Enter the 6-digit code we sent to \(state.phoneNumber)." + : "For your security, we'll text a code to \(state.phoneNumber)." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + if state.codeSent { + ProField( + label: "Verification code", + placeholder: "6-digit code", + text: state.verificationCode, + keyboard: .numberPad, + contentType: .oneTimeCode, + identifier: "reauth-code-field" + ) + } + ProErrorText(message: state.errorMessage) + if state.codeSent { + ProPrimaryButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "reauth-verify-button", + action: state.verify + ) + .disabled(!state.isValid) + .padding(.top, 4) + } else { + ProPrimaryButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "reauth-send-code-button", + action: state.sendCode + ) + .padding(.top, 4) + } + } + if state.codeSent { + ProTextButton( + title: "Resend code", + identifier: "reauth-resend-button", + action: state.sendCode + ) + .disabled(state.isLoading) + .padding(.top, 4) + } + ProTextButton(title: "Cancel", identifier: "reauth-cancel-button", action: state.cancel) + } + case let .emailLink(state): + ProPage(showsThemeMenu: false) { + ProHeader( + symbol: "envelope.badge", + title: "Check your inbox", + subtitle: state.linkSent + ? "We sent a sign-in link to \(state.email). Open it on this device to continue." + : "Sending a sign-in link to \(state.email)." + ) + } content: { + VStack(alignment: .leading, spacing: 0) { + ProErrorText(message: state.errorMessage) + ProTextButton( + title: "Resend link", + identifier: "reauth-resend-button", + action: state.resendLink + ) + .disabled(state.isLoading) + ProTextButton(title: "Cancel", identifier: "reauth-cancel-button", action: state.cancel) + } + } + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoTheme.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoTheme.swift new file mode 100644 index 00000000000..85cf3eba7e6 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/DemoTheme.swift @@ -0,0 +1,481 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import SwiftUI + +/// The looks of the full customization demo. Doggo, Grid and Coffee Spark come from the iOS +/// Landing frames in the "Firebase auth flows" Figma file (17:12933, 20:5467 and 20:5769), and +/// screens the frames don't cover reuse their values. Professional has no Figma frame: its values +/// are a written spec in ``DemoTheme/professional``. +enum DemoThemeID: String, CaseIterable, Identifiable { + case doggo + case grid + case spark + case professional + + static let storageKey = "fullCustomization.theme" + + var id: String { rawValue } + + var label: String { + switch self { + case .doggo: "Doggo" + case .grid: "Grid" + case .spark: "Coffee Spark" + case .professional: "Professional" + } + } + + var theme: DemoTheme { + switch self { + case .doggo: .doggo + case .grid: .grid + case .spark: .spark + case .professional: .professional + } + } +} + +struct DemoTheme { + struct TextToken { + /// A bundled font's PostScript name, or `nil` for the system font at ``weight``. + var fontName: String? + var size: CGFloat + var weight: Font.Weight = .regular + var color: Color + var underline = false + + func font(relativeTo style: Font.TextStyle = .body) -> Font { + if let fontName { + .custom(fontName, size: size, relativeTo: style) + } else { + .system(size: size, weight: weight) + } + } + } + + struct ButtonColors { + var fill: Color + var label: Color + } + + struct Shadow { + var color: Color + var x: CGFloat + var y: CGFloat + } + + struct Stroke { + var color: Color + var width: CGFloat + } + + struct Provider { + var fill: Color + var label: DemoTheme.TextToken + var cornerRadius: CGFloat + } + + let id: DemoThemeID + /// The family handed to `AuthTypography`, so library screens the demo doesn't replace (update + /// password, reauthentication, delete confirmation) use the theme's font too. `nil` keeps the + /// system font. + let libraryFontFamily: String? + let pageBackground: Color + + /// The "title" text above the form. Coffee Spark hides it in Figma and shows ``headline``. + let title: TextToken + let showsTitle: Bool + /// Space between the title and the form: the "sign up" frame's title-to-inputs offset. + let titleSpacing: CGFloat + + /// The "inputs" frame: Doggo draws it as a card; Grid and Coffee Spark leave it bare. + let cardFill: Color? + let cardCornerRadius: CGFloat + let cardPadding: CGFloat + let cardShadow: Shadow? + let cardStroke: Stroke? + + /// The "text-field" instance. + let fieldFill: Color + let fieldStroke: Stroke + let fieldCornerRadius: CGFloat + let fieldText: TextToken + let fieldPlaceholder: Color + let fieldIcon: Color + let fieldIconWeight: Font.Weight + let fieldTrailingIcon: Color + + let link: TextToken + + /// The "button" instances: primary is "Login", secondary is "Email link sign-in". + let buttonFont: TextToken + let buttonCornerRadius: CGFloat + let buttonHeight: CGFloat + let buttonStroke: Stroke? + let buttonShadow: Shadow? + let primaryButton: ButtonColors + let secondaryButton: ButtonColors + + /// The "Content" frame below the form. + let dividerColor: Color + let otherLabel: TextToken + let otherPadding: EdgeInsets + let googleLabel: TextToken + /// The Google and "More sign-in options" pills. + let pillFill: Color + let pillCornerRadius: CGFloat + let pillStroke: Color + let phoneProvider: Provider + let footerText: TextToken + let footerAccent: TextToken + + let errorColor = Color(demoHex: 0xBA1A1A) + + var body: TextToken { otherLabel } +} + +extension DemoTheme { + /// Figma 17:12933 "Landing". + static let doggo = DemoTheme( + id: .doggo, + libraryFontFamily: "Onest", + pageBackground: Color(demoHex: 0xFFF8F8), + title: TextToken(fontName: "BagelFatOne-Regular", size: 28, color: Color(demoHex: 0x864B6F)), + showsTitle: true, + titleSpacing: 12, + cardFill: Color(demoHex: 0xFFF8F8), + cardCornerRadius: 24, + cardPadding: 16, + cardShadow: Shadow(color: Color(demoHex: 0xFFD8EB), x: 3, y: 6), + cardStroke: nil, + fieldFill: .white, + fieldStroke: Stroke(color: Color(demoHex: 0xD3C2C9), width: 1), + fieldCornerRadius: 24, + fieldText: TextToken(fontName: "Onest-Medium", size: 16, color: Color(demoHex: 0x211A1D)), + fieldPlaceholder: Color(demoHex: 0xA08B95), + fieldIcon: Color(demoHex: 0xA08B95), + fieldIconWeight: .bold, + fieldTrailingIcon: Color(demoHex: 0x49454F), + link: TextToken( + fontName: "Onest-Medium", + size: 16, + color: Color(demoHex: 0x864B6F), + underline: true + ), + buttonFont: TextToken(fontName: "Onest-Bold", size: 20, color: .white), + buttonCornerRadius: 36, + buttonHeight: 64, + buttonStroke: nil, + buttonShadow: Shadow(color: Color(demoHex: 0x5D0B47), x: 2, y: 4), + primaryButton: ButtonColors(fill: Color(demoHex: 0x864B6F), label: .white), + secondaryButton: ButtonColors(fill: Color(demoHex: 0xFFD8EB), label: Color(demoHex: 0x7B3B73)), + dividerColor: Color(demoHex: 0x864B6F), + otherLabel: TextToken(fontName: "Onest-Medium", size: 16, color: Color(demoHex: 0xA08B95)), + otherPadding: EdgeInsets(top: 16, leading: 40, bottom: 0, trailing: 40), + googleLabel: TextToken(fontName: "Roboto-SemiBold", size: 14, color: .black), + pillFill: .white, + pillCornerRadius: 80, + pillStroke: .black, + phoneProvider: Provider( + fill: Color(demoHex: 0x894680), + label: TextToken(fontName: "Roboto-Medium", size: 14, color: .white), + cornerRadius: 80 + ), + footerText: TextToken(fontName: "Onest-Medium", size: 14, color: Color(demoHex: 0x202124)), + footerAccent: TextToken(fontName: "Onest-Bold", size: 14, color: Color(demoHex: 0x894680)) + ) + + /// Figma 20:5467 "iOS Landing" with the grid background. + static let grid = DemoTheme( + id: .grid, + libraryFontFamily: "Chivo Mono", + pageBackground: Color(demoHex: 0xF3FBFB), + title: TextToken(fontName: "Rowdies-Regular", size: 28, color: Color(demoHex: 0x2C1067)), + showsTitle: true, + titleSpacing: 12, + cardFill: nil, + cardCornerRadius: 24, + cardPadding: 0, + cardShadow: nil, + cardStroke: nil, + fieldFill: .white, + fieldStroke: Stroke(color: Color(demoHex: 0x00DCE2), width: 2), + fieldCornerRadius: 16, + fieldText: TextToken(fontName: "ChivoMono-Medium", size: 16, color: Color(demoHex: 0x2C1067)), + fieldPlaceholder: Color(demoHex: 0x2C1067), + fieldIcon: Color(demoHex: 0x2C1067), + fieldIconWeight: .regular, + fieldTrailingIcon: Color(demoHex: 0x2C1067), + link: TextToken(fontName: "ChivoMono-Medium", size: 16, color: Color(demoHex: 0xFF52B3)), + buttonFont: TextToken(fontName: "Rowdies-Bold", size: 20, color: Color(demoHex: 0x2C1067)), + buttonCornerRadius: 24, + buttonHeight: 64, + buttonStroke: Stroke(color: Color(demoHex: 0x00DCE2), width: 2), + buttonShadow: Shadow(color: Color(demoHex: 0x00DCE2), x: 3, y: 6), + primaryButton: ButtonColors(fill: Color(demoHex: 0xFF52B3), label: Color(demoHex: 0x2C1067)), + secondaryButton: ButtonColors(fill: Color(demoHex: 0xF3FBFB), label: Color(demoHex: 0x2C1067)), + dividerColor: Color(demoHex: 0x864B6F), + otherLabel: TextToken(fontName: "ChivoMono-Medium", size: 16, color: Color(demoHex: 0x2C1067)), + otherPadding: EdgeInsets(top: 16, leading: 40, bottom: 0, trailing: 40), + googleLabel: TextToken(fontName: "Roboto-SemiBold", size: 14, color: .black), + pillFill: .white, + pillCornerRadius: 80, + pillStroke: .black, + phoneProvider: Provider( + fill: Color(demoHex: 0x00DCE2), + label: TextToken(fontName: "ChivoMono-Medium", size: 14, color: Color(demoHex: 0x2C1067)), + cornerRadius: 12 + ), + footerText: TextToken(fontName: "ChivoMono-Medium", size: 14, color: Color(demoHex: 0x202124)), + footerAccent: TextToken( + fontName: "ChivoMono-Bold", + size: 14, + color: Color(demoHex: 0xFF52B3), + underline: true + ) + ) + + /// Figma 20:5769 "iOS Landing" with the Coffee Spark headline. + static let spark = DemoTheme( + id: .spark, + libraryFontFamily: "JetBrains Mono", + pageBackground: Color(demoHex: 0xF1F1F1), + // The frame's "title" text is hidden; this size is the Doggo and Grid title's, used on the + // screens Figma doesn't cover. + title: TextToken(fontName: "JetBrainsMono-Medium", size: 28, color: Color(demoHex: 0x1C1B1B)), + showsTitle: false, + titleSpacing: 12, + cardFill: nil, + cardCornerRadius: 24, + cardPadding: 0, + cardShadow: nil, + cardStroke: nil, + fieldFill: .white, + fieldStroke: Stroke(color: .black, width: 2), + fieldCornerRadius: 16, + fieldText: TextToken(fontName: "JetBrainsMono-Medium", size: 16, color: .black), + fieldPlaceholder: .black, + fieldIcon: .black, + fieldIconWeight: .regular, + fieldTrailingIcon: .black, + link: TextToken(fontName: "JetBrainsMono-Medium", size: 16, color: Color(demoHex: 0x34A853)), + buttonFont: TextToken(fontName: "JetBrainsMono-Bold", size: 20, color: .black), + buttonCornerRadius: 40, + buttonHeight: 64, + buttonStroke: Stroke(color: .black, width: 2), + buttonShadow: nil, + primaryButton: ButtonColors(fill: Color(demoHex: 0xFDADEE), label: .black), + secondaryButton: ButtonColors(fill: Color(demoHex: 0xF3FBFB), label: .black), + dividerColor: Color(demoHex: 0xFDFCFB), + otherLabel: TextToken(fontName: "JetBrainsMono-Medium", size: 16, color: .black), + otherPadding: EdgeInsets(top: 24, leading: 54, bottom: 0, trailing: 54), + googleLabel: TextToken(fontName: "Roboto-SemiBold", size: 14, color: .black), + pillFill: .white, + pillCornerRadius: 80, + pillStroke: .black, + phoneProvider: Provider( + fill: Color(demoHex: 0xC6FF00), + label: TextToken(fontName: "ChivoMono-Medium", size: 14, color: Color(demoHex: 0x34A853)), + cornerRadius: 40 + ), + footerText: TextToken(fontName: "ChivoMono-Medium", size: 14, color: Color(demoHex: 0x202124)), + footerAccent: TextToken( + fontName: "ChivoMono-Bold", + size: 14, + color: Color(demoHex: 0x34A853), + underline: true + ) + ) +} + +extension DemoTheme { + /// Not from Figma: a quiet, platform-native look written as a spec. The system font, neutral + /// greys with one blue accent, 10-point corners, hairline borders instead of offset shadows, + /// no background art, and light and dark variants. + static let professional: DemoTheme = { + let text = Color(demoLight: 0x111418, dark: 0xF2F4F7) + let muted = Color(demoLight: 0x5B6470, dark: 0x9AA3AE) + let accent = Color(demoLight: 0x1F5EFF, dark: 0x4C7DFF) + // Darker than `accent` in dark mode so white labels on it keep 4.5:1 contrast. + let accentFill = Color(demoLight: 0x1F5EFF, dark: 0x2F62F0) + let hairline = Color(demoLight: 0xE3E6EA, dark: 0x2A2F36) + return DemoTheme( + id: .professional, + libraryFontFamily: nil, + pageBackground: Color(demoLight: 0xF5F6F8, dark: 0x0F1115), + title: TextToken(size: 28, weight: .semibold, color: text), + showsTitle: true, + titleSpacing: 16, + cardFill: Color(demoLight: 0xFFFFFF, dark: 0x181B20), + cardCornerRadius: 16, + cardPadding: 20, + cardShadow: nil, + cardStroke: Stroke(color: hairline, width: 1), + fieldFill: Color(demoLight: 0xFFFFFF, dark: 0x0F1115), + fieldStroke: Stroke(color: Color(demoLight: 0xC9CFD6, dark: 0x3A414A), width: 1), + fieldCornerRadius: 10, + fieldText: TextToken(size: 16, color: text), + fieldPlaceholder: Color(demoLight: 0x6B7480, dark: 0x7D8693), + fieldIcon: muted, + fieldIconWeight: .regular, + fieldTrailingIcon: muted, + link: TextToken(size: 15, weight: .medium, color: accent), + buttonFont: TextToken(size: 17, weight: .semibold, color: .white), + buttonCornerRadius: 10, + buttonHeight: 52, + buttonStroke: nil, + buttonShadow: nil, + primaryButton: ButtonColors(fill: accentFill, label: .white), + secondaryButton: ButtonColors( + fill: Color(demoLight: 0xEEF2FF, dark: 0x1C2540), + label: Color(demoLight: 0x1F5EFF, dark: 0x9DB5FF) + ), + dividerColor: hairline, + otherLabel: TextToken(size: 15, color: muted), + otherPadding: EdgeInsets(top: 20, leading: 24, bottom: 0, trailing: 24), + googleLabel: TextToken(size: 15, weight: .semibold, color: text), + pillFill: Color(demoLight: 0xFFFFFF, dark: 0x181B20), + pillCornerRadius: 10, + pillStroke: Color(demoLight: 0xC9CFD6, dark: 0x3A414A), + phoneProvider: Provider( + fill: accentFill, + label: TextToken(size: 15, weight: .semibold, color: .white), + cornerRadius: 10 + ), + footerText: TextToken(size: 14, color: muted), + footerAccent: TextToken(size: 14, weight: .semibold, color: accent) + ) + }() +} + +// MARK: - Library theming + +extension DemoTheme { + var typography: AuthTypography { + AuthTypography(fontName: libraryFontFamily) + } + + var textFieldStyle: AuthTextFieldStyle { + AuthTextFieldStyle( + tint: fieldStroke.color, + containerColor: fieldFill, + secondaryColor: fieldPlaceholder, + errorColor: errorColor, + cornerRadius: fieldCornerRadius + ) + } + + var ctaButtonStyle: AuthCTAButtonStyle { + AuthCTAButtonStyle( + backgroundColor: primaryButton.fill, + contentColor: primaryButton.label, + shape: .roundedRectangle(radius: buttonCornerRadius), + font: buttonFont.font() + ) + } +} + +// MARK: - Background art + +/// The background layers of each frame, placed at their Figma offsets from the frame's top-left +/// corner: "bg" and "pattern" in Doggo, "Frame 5" in Grid and "avatar spark" in Coffee Spark. +struct DemoThemeBackground: View { + let theme: DemoTheme + + var body: some View { + // An overlay, so the art larger than the screen doesn't size the background itself. + theme.pageBackground + .overlay(alignment: .topLeading) { art } + .clipped() + .ignoresSafeArea() + .accessibilityHidden(true) + } + + private var art: some View { + ZStack(alignment: .topLeading) { + switch theme.id { + case .doggo: + DoggoGradient() + .frame(width: 441.481, height: 934.09) + .offset(x: -8, y: 0) + art("doggo-pattern", x: -160, y: -680, width: 1031, height: 1042) + case .grid: + art("grid-pattern", x: -269, y: -75, width: 997, height: 997) + case .spark: + // The export includes the layer's texture and noise effects, so it is larger than the + // 583x568 layer; it is centred on the layer's bounds. + art("spark-blob", x: -295.8, y: -317.7, width: 751, height: 735.3) + case .professional: + EmptyView() + } + } + } + + private func art(_ name: String, x: CGFloat, y: CGFloat, width: CGFloat, + height: CGFloat) -> some View { + Image("FullCustomization/\(name)") + .resizable() + .frame(width: width, height: height) + .offset(x: x, y: y) + .accessibilityIdentifier(name) + } +} + +/// Figma "bg" (17:12934): a radial gradient whose ellipse is given by three handle points in the +/// layer's unit square, drawn by mapping a unit circle onto that ellipse. +private struct DoggoGradient: View { + var body: some View { + Canvas { context, size in + let origin = CGPoint(x: -0.03 * size.width, y: 0) + let axisX = CGPoint(x: 0.95 * size.width - origin.x, y: 0.55 * size.height - origin.y) + let axisY = CGPoint(x: -0.83 * size.width - origin.x, y: 1.08 * size.height - origin.y) + var unit = context + unit.transform = CGAffineTransform( + a: axisX.x, b: axisX.y, c: axisY.x, d: axisY.y, tx: origin.x, ty: origin.y + ) + unit.fill( + Path(CGRect(x: -2, y: -2, width: 4, height: 4)), + with: .radialGradient( + Gradient(stops: [ + .init(color: Color(demoHex: 0xFFF8F8), location: 0.04), + .init(color: Color(demoHex: 0xFFDDB4), location: 0.34), + .init(color: Color(demoHex: 0xFFD8EB), location: 0.74), + ]), + center: .zero, + startRadius: 0, + endRadius: 1 + ) + ) + } + } +} + +extension Color { + /// A colour that follows the system's light or dark appearance. + init(demoLight light: UInt32, dark: UInt32) { + self.init(UIColor { traits in + UIColor(Color(demoHex: traits.userInterfaceStyle == .dark ? dark : light)) + }) + } + + init(demoHex hex: UInt32) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/BagelFatOne-400.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/BagelFatOne-400.ttf new file mode 100644 index 00000000000..2caed33cea2 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/BagelFatOne-400.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/ChivoMono-500.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/ChivoMono-500.ttf new file mode 100644 index 00000000000..232a06976f8 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/ChivoMono-500.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/ChivoMono-700.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/ChivoMono-700.ttf new file mode 100644 index 00000000000..93a56f6de48 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/ChivoMono-700.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/JetBrainsMono-500.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/JetBrainsMono-500.ttf new file mode 100644 index 00000000000..51ccd916b8e Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/JetBrainsMono-500.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/JetBrainsMono-700.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/JetBrainsMono-700.ttf new file mode 100644 index 00000000000..1305efce9d6 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/JetBrainsMono-700.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-bagelfatone.txt b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-bagelfatone.txt new file mode 100644 index 00000000000..21c7b2d4d41 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-bagelfatone.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-chivomono.txt b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-chivomono.txt new file mode 100644 index 00000000000..4e5e18cc08e --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-chivomono.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Chivo Project Authors (https://github.com/Omnibus-Type/Chivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-jetbrainsmono.txt b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-jetbrainsmono.txt new file mode 100644 index 00000000000..821a3dac22a --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-jetbrainsmono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-onest.txt b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-onest.txt new file mode 100644 index 00000000000..b6f4f914879 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-onest.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Onest Project Authors (https://github.com/googlefonts/onest) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-roboto.txt b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-roboto.txt new file mode 100644 index 00000000000..9c48e05a24f --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-roboto.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-rowdies.txt b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-rowdies.txt new file mode 100644 index 00000000000..f51da0012cd --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/OFL-rowdies.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Rowdies Project Authors (https://github.com/magictype/rowdy) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Onest-500.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Onest-500.ttf new file mode 100644 index 00000000000..bf4d81d5713 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Onest-500.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Onest-700.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Onest-700.ttf new file mode 100644 index 00000000000..e5811290b87 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Onest-700.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Roboto-500.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Roboto-500.ttf new file mode 100644 index 00000000000..310c9e48da2 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Roboto-500.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Roboto-600.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Roboto-600.ttf new file mode 100644 index 00000000000..2ef85eada43 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Roboto-600.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Rowdies-400.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Rowdies-400.ttf new file mode 100644 index 00000000000..f65911f72a2 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Rowdies-400.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Rowdies-700.ttf b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Rowdies-700.ttf new file mode 100644 index 00000000000..ba852339da2 Binary files /dev/null and b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/Fonts/Rowdies-700.ttf differ diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/FullCustomizationExample.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/FullCustomizationExample.swift new file mode 100644 index 00000000000..9b4199c986f --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/FullCustomizationExample.swift @@ -0,0 +1,224 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAppleSwiftUI +import FirebaseAuth +import FirebaseAuthSwiftUI +import FirebaseGoogleSwiftUI +import FirebaseOAuthSwiftUI +import FirebasePhoneAuthSwiftUI +import SwiftUI + +/// Every screen of the authentication sheet drawn by the app, while the library keeps the auth +/// logic, navigation, MFA, account conflicts and reauthentication. +/// +/// - `.pickerContent { state in ... }` replaces the whole first screen. +/// - `.pickerDestination` replaces each pushed screen through its content slot; update password +/// has no slot and stays stock, falling through to `DefaultAuthPickerDestination`. +/// - `.signedInContent` replaces the signed-in screen. +/// - `.reauthContent` replaces the password, phone and email-link reauthentication prompts. +/// - The theming modifiers restyle the stock screens left in the flow (update password, delete +/// confirmation) and the library components the demo reuses. +/// +/// The Figma themes share one layout and differ only in ``DemoTheme`` values. Professional also +/// brings its own layout, the `Pro…` screens, through the same slots, and pushes a separate +/// sign-up screen on the `.signUp` route. +struct FullCustomizationExample: View { + /// Its own service, so it can turn on email-link sign-in (drawn in the Figma frames) without + /// changing the other examples. + /// Created when the example appears: `NavigationLink` builds its destination eagerly. + @State private var authService: AuthService? + + var body: some View { + if let authService { + FullCustomizationFlow() + .environment(authService) + } else { + Color.clear.onAppear { + authService = AuthService(configuration: Self.configuration) + .withEmailSignIn() + .withEmailLinkSignIn() + .withGoogleSignIn() + .withPhoneSignIn() + .withAppleSignIn() + .withOAuthSignIn(OAuthProviderSwift.github()) + } + } + } + + private static var configuration: AuthConfiguration { + let actionCodeSettings = ActionCodeSettings() + actionCodeSettings.handleCodeInApp = true + actionCodeSettings.url = URL(string: "https://flutterfire-e2e-tests.firebaseapp.com") + actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!) + actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" + return AuthConfiguration( + tosUrl: URL(string: "https://example.com/tos"), + privacyPolicyUrl: URL(string: "https://example.com/privacy"), + emailLinkSignInActionCodeSettings: actionCodeSettings, + mfaEnabled: true + ) + } +} + +private struct FullCustomizationFlow: View { + @Environment(AuthService.self) private var authService + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + let theme = themeID.theme + AuthPickerView { + home + } + .pickerContent { state in + ThemedLanding(pickerState: state) + } + .pickerDestination { screen in + ThemedDestination(screen: screen) + } + .signedInContent { state in + ThemedSignedIn(state: state) + } + .reauthContent { prompt in + ThemedReauth(prompt: prompt) + } + .authTypography(theme.typography) + .authTextFieldStyle(theme.textFieldStyle) + .authCTAButtonStyle(theme.ctaButtonStyle) + } + + private var home: some View { + VStack(spacing: 24) { + Picker("Theme", selection: $themeID) { + ForEach(DemoThemeID.allCases) { theme in + Text(theme.label).tag(theme) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("theme-picker") + + if authService.authenticationState == .authenticated { + let user = authService.currentUser + Text("Signed in as \(user?.email ?? user?.phoneNumber ?? "")") + .authFont(.body) + DemoButton(title: "Manage account", identifier: "manage-account-button") { + authService.isPresented = true + } + } else { + Text("Not signed in") + .authFont(.body) + DemoButton(title: "Sign in", identifier: "sign-in-sheet-button") { + authService.isPresented = true + } + } + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { DemoThemeBackground(theme: themeID.theme) } + } +} + +/// The first screen for the selected theme. +private struct ThemedLanding: View { + let pickerState: AuthMethodPickerState + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + if themeID == .professional { + ProLanding(pickerState: pickerState) + } else { + DemoLanding(pickerState: pickerState) + } + } +} + +/// The pushed screens for the selected theme. Update password has no content slot, so it stays +/// stock in every theme. +private struct ThemedDestination: View { + let screen: AuthView + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + if themeID == .professional { + professional + } else { + figma + } + } + + @ViewBuilder + private var professional: some View { + switch screen { + case .signUp: + ProSignUp() + case .passwordRecovery: + ProPasswordRecovery() + case .emailLink: + ProEmailLink() + case .enterPhoneNumber: + ProEnterPhoneNumber() + case let .enterVerificationCode(verificationID, fullPhoneNumber): + ProEnterVerificationCode(verificationID: verificationID, fullPhoneNumber: fullPhoneNumber) + case .mfaEnrollment: + ProMFAEnrolment() + case .mfaManagement: + ProMFAManagement() + case let .mfaResolution(mfaRequired): + ProMFAResolution(mfaRequired: mfaRequired) + default: + DefaultAuthPickerDestination(screen: screen) + } + } + + /// The Figma themes switch sign-in and sign-up in place on the first screen, so they never + /// push `.signUp`. + @ViewBuilder + private var figma: some View { + switch screen { + case .passwordRecovery: + DemoPasswordRecovery() + case .emailLink: + DemoEmailLink() + case .enterPhoneNumber: + DemoEnterPhoneNumber() + case let .enterVerificationCode(verificationID, fullPhoneNumber): + DemoEnterVerificationCode(verificationID: verificationID, fullPhoneNumber: fullPhoneNumber) + case .mfaEnrollment: + DemoMFAEnrolment() + case .mfaManagement: + DemoMFAManagement() + case let .mfaResolution(mfaRequired): + DemoMFAResolution(mfaRequired: mfaRequired) + default: + DefaultAuthPickerDestination(screen: screen) + } + } +} + +/// The signed-in screen for the selected theme. +private struct ThemedSignedIn: View { + let state: SignedInContentState + + @AppStorage(DemoThemeID.storageKey) private var themeID: DemoThemeID = .doggo + + var body: some View { + if themeID == .professional { + ProAccount(state: state) + } else { + DemoSignedIn(state: state) + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalAccountScreens.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalAccountScreens.swift new file mode 100644 index 00000000000..0160d45f80f --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalAccountScreens.swift @@ -0,0 +1,522 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuth +import FirebaseAuthSwiftUI +import SwiftUI + +/// The Professional signed-in screen: an account settings page with grouped rows. +struct ProAccount: View { + let state: SignedInContentState + + private var user: User? { state.user } + + private var providerIDs: [String] { + user?.providerData.map(\.providerID) ?? [] + } + + /// Only accounts that sign in with a password can change one. + private var hasPassword: Bool { providerIDs.contains("password") } + + private var signInMethod: String { + switch providerIDs.first { + case "password": "email" + case "phone": "phone" + case "google.com": "Google" + case "apple.com": "Apple" + case "github.com": "GitHub" + case let other?: other + case nil: "a temporary account" + } + } + + private var isTwoFactorOn: Bool { + !(user?.multiFactor.enrolledFactors.isEmpty ?? true) + } + + var body: some View { + ProPage { + ProHeader(title: "Account") + } content: { + VStack(alignment: .leading, spacing: 0) { + profile + ProErrorText(message: state.errorMessage) + .padding(.top, 16) + security + ProGroup { + ProRow( + title: "Sign out", + tint: ProPalette.accent, + showsChevron: false, + identifier: "sign-out-button", + action: state.signOut + ) + } + .padding(.top, 24) + ProGroup { + ProRow( + title: "Delete account", + tint: ProPalette.danger, + showsChevron: false, + identifier: "delete-account-button", + action: state.deleteAccount + ) + } + .padding(.top, 24) + Text( + "Deleting your account removes your data permanently. You may be asked to sign in again first." + ) + .font(.system(size: 13)) + .foregroundStyle(ProPalette.muted) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 4) + .padding(.top, 8) + } + .disabled(state.isLoading) + } + } + + private var profile: some View { + let identity = user?.email ?? user?.phoneNumber ?? "Signed in" + return HStack(spacing: 14) { + Text(String(identity.prefix(1)).uppercased()) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(ProPalette.accentSoftText) + .frame(width: 56, height: 56) + .background(ProPalette.accentSoft, in: Circle()) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(identity) + .font(.system(size: 17, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Text("Signed in with \(signInMethod)") + .font(.system(size: 14)) + .foregroundStyle(ProPalette.muted) + } + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("profile") + } + + @ViewBuilder + private var security: some View { + let email = user?.email + if hasPassword || state.isMFAEnabled || email != nil { + ProSectionLabel(text: "Security") + .padding(.top, 32) + ProGroup { + if hasPassword { + ProRow( + symbol: "key", + title: "Password", + value: "Change", + identifier: "update-password-button", + action: state.updatePassword + ) + } + if state.isMFAEnabled { + if hasPassword { + ProRowSeparator() + } + ProRow( + symbol: "lock.shield", + title: "Two-factor authentication", + value: isTwoFactorOn ? "On" : "Off", + identifier: "mfa-management-button", + action: state.goToMFAManagement + ) + } + if email != nil { + if hasPassword || state.isMFAEnabled { + ProRowSeparator() + } + ProRow( + symbol: "envelope", + title: "Email", + value: state.isEmailVerified ? "Verified" : "Verify", + valueTint: state.isEmailVerified ? ProPalette.muted : ProPalette.accent, + showsChevron: false, + identifier: "verify-email-button", + action: state.isEmailVerified ? {} : state.verifyEmail + ) + .disabled(state.isEmailVerified) + } + } + } + } +} + +struct ProMFAManagement: View { + var body: some View { + MFAManagementScreen { state in + ProPage { + ProHeader( + symbol: "lock.shield", + title: "Two-factor authentication", + subtitle: state.enrolledFactors.isEmpty + ? "Add a second step to sign-in, so a password on its own isn't enough." + : "These methods can confirm it's you when you sign in." + ) + } content: { + VStack(alignment: .leading, spacing: 0) { + if !state.enrolledFactors.isEmpty { + ProSectionLabel(text: "Your methods") + ProGroup { + let factors = Array(state.enrolledFactors.enumerated()) + ForEach(factors, id: \.element.uid) { index, factor in + if index > 0 { + ProRowSeparator(inset: 66) + } + ProFactorRow(factor: factor) { + state.unenroll(factor.uid) + } + } + } + .disabled(state.isLoading) + } + ProErrorText(message: state.errorMessage) + .padding(.top, 16) + ProPrimaryButton( + title: state.enrolledFactors.isEmpty ? "Set up two-factor" : "Add another method", + isLoading: state.isLoading, + identifier: "add-factor-button", + action: state.goToEnrollment + ) + .padding(.top, 8) + } + } + } + } +} + +/// An enrolled second factor, with a Remove action. +private struct ProFactorRow: View { + let factor: MultiFactorInfo + let remove: () -> Void + + private var isPhone: Bool { factor.factorID == PhoneMultiFactorID } + + var body: some View { + HStack(spacing: 14) { + ProIconTile(symbol: isPhone ? "message" : "iphone") + VStack(alignment: .leading, spacing: 2) { + Text(factor.displayName ?? (isPhone ? "Text message" : "Authenticator app")) + .font(.system(size: 16, weight: .medium)) + Text(isPhone + ? (factor as? PhoneMultiFactorInfo)?.phoneNumber ?? "Text message" + : "Authenticator app") + .font(.system(size: 14)) + .foregroundStyle(ProPalette.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + Button("Remove", action: remove) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(ProPalette.danger) + .frame(minHeight: 44) + .buttonStyle(.plain) + .accessibilityIdentifier("remove-factor-button") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } +} + +struct ProMFAEnrolment: View { + var body: some View { + MFAEnrolmentScreen { ProMFAEnrolmentContent(state: $0) } + } +} + +/// The enrolment steps, apart from `MFAEnrolmentScreen` so they can be previewed with a stub +/// state: the Auth emulator can't enrol TOTP. +struct ProMFAEnrolmentContent: View { + let state: MFAEnrollmentContentState + + var body: some View { + switch state.step { + case .selectFactor: + ProPage { + ProHeader( + symbol: "lock.shield", + title: "Set up two-factor authentication", + subtitle: "Add a second step to sign-in, so a password on its own isn't enough." + ) + } content: { + VStack(alignment: .leading, spacing: 0) { + ProSectionLabel(text: "Choose a method") + ProGroup { + ForEach(Array(state.allowedFactors.enumerated()), id: \.element) { index, factor in + if index > 0 { + ProRowSeparator(inset: 66) + } + ProChoiceRow( + symbol: factor == .sms ? "message" : "iphone", + title: factor == .sms ? "Text message" : "Authenticator app", + detail: factor == .sms + ? "We'll text a code to your phone." + : "Use a code from an app like Google Authenticator.", + isSelected: state.selectedFactorType.wrappedValue == factor, + identifier: factor == .sms ? "sms-factor-button" : "totp-factor-button" + ) { + state.selectedFactorType.wrappedValue = factor + } + } + } + ProErrorText(message: state.isMFAEnabled + ? state.errorMessage + : "Two-factor sign-in isn't enabled for this app.") + .padding(.top, 16) + ProPrimaryButton( + title: "Continue", + isLoading: state.isLoading, + identifier: "start-enrollment-button", + action: state.startEnrollment + ) + .disabled(!state.isValid) + .padding(.top, 12) + } + } + case .enterPhoneNumber: + ProPage { + ProHeader( + symbol: "message", + title: "Add your phone number", + subtitle: "We'll text a code to this number whenever you sign in." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + nameField + ProPhoneField(phoneNumber: state.phoneNumber, country: state.selectedCountry) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "send-code-button", + action: state.sendCode + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + } + case .enterVerificationCode: + ProPage { + ProHeader( + symbol: "number", + title: "Confirm the code", + subtitle: "We sent a 6-digit code to \(state.sentPhoneNumber ?? "your phone")." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + codeField(state.verificationCode) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "enroll-button", + action: state.enroll + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + ProTextButton( + title: "Resend code", + identifier: "resend-code-button", + action: state.sendCode + ) + .disabled(state.isLoading) + .padding(.top, 4) + } + case .setUpTOTP: + ProPage { + ProHeader( + symbol: "qrcode", + title: "Scan to set up", + subtitle: "Scan the code with your authenticator app, or enter the key by hand." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + if let url = state.totpQRCodeURL { + DemoQRCode(url: url) + } + if let secret = state.totpSecret { + HStack(spacing: 12) { + Text(secret) + .font(.system(size: 15, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + Button(state.didCopySecret ? "Copied" : "Copy", action: state.copySecret) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(ProPalette.accent) + .buttonStyle(.plain) + .accessibilityIdentifier("copy-secret-button") + } + .padding(.horizontal, 14) + .frame(height: 48) + .proFieldBox() + .accessibilityIdentifier("totp-secret") + } + nameField + codeField(state.totpCode) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "enroll-button", + action: state.enroll + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + } + } + } + + private var nameField: some View { + ProField( + label: "Name", + placeholder: "For example, Work phone", + text: state.displayName, + identifier: "display-name-field" + ) + } + + private func codeField(_ code: Binding) -> some View { + ProField( + label: "Verification code", + placeholder: "6-digit code", + text: code, + keyboard: .numberPad, + contentType: .oneTimeCode, + identifier: "verification-code-field" + ) + } +} + +struct ProMFAResolution: View { + let mfaRequired: MFARequired + + var body: some View { + MFAResolutionScreen(mfaRequired: mfaRequired) { state in + let hint = selectedHint(state) + ProPage { + ProHeader(symbol: "lock.shield", title: "Verify it's you", subtitle: subtitle(hint, state)) + } content: { + VStack(alignment: .leading, spacing: 0) { + if state.hints.count > 1 { + ProSectionLabel(text: "Choose a method") + ProGroup { + ForEach(Array(state.hints.enumerated()), id: \.offset) { index, option in + if index > 0 { + ProRowSeparator(inset: 66) + } + ProChoiceRow( + symbol: isPhone(option) ? "message" : "iphone", + title: title(for: option), + detail: detail(for: option), + isSelected: state.selectedHintIndex.wrappedValue == index, + identifier: "hint-\(index)-button" + ) { + state.selectedHintIndex.wrappedValue = index + } + } + } + .padding(.bottom, 24) + } + VStack(alignment: .leading, spacing: 16) { + if let hint, !isPhone(hint) || state.isCodeSent { + ProField( + label: "Verification code", + placeholder: "6-digit code", + text: isPhone(hint) ? state.verificationCode : state.totpCode, + keyboard: .numberPad, + contentType: .oneTimeCode, + identifier: "verification-code-field" + ) + } + ProErrorText(message: state.errorMessage) + if let hint, isPhone(hint), !state.isCodeSent { + ProPrimaryButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "send-code-button", + action: state.sendCode + ) + } else { + ProPrimaryButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "resolve-button", + action: state.resolve + ) + .disabled(!state.isValid) + } + } + if let hint, isPhone(hint), state.isCodeSent { + ProTextButton( + title: "Resend code", + identifier: "resend-code-button", + action: state.sendCode + ) + .disabled(state.isLoading) + .padding(.top, 4) + } + ProTextButton(title: "Cancel", identifier: "cancel-button", action: state.cancel) + .padding(.top, 4) + } + } + } + } + + private func selectedHint(_ state: MFAResolutionContentState) -> MFAHint? { + let index = state.selectedHintIndex.wrappedValue + return state.hints.indices.contains(index) ? state.hints[index] : nil + } + + private func isPhone(_ hint: MFAHint) -> Bool { + if case .phone = hint { + return true + } + return false + } + + private func title(for hint: MFAHint) -> String { + switch hint { + case let .phone(displayName, _, _): displayName ?? "Text message" + case let .totp(displayName, _): displayName ?? "Authenticator app" + } + } + + private func detail(for hint: MFAHint) -> String { + switch hint { + case let .phone(_, _, phoneNumber): phoneNumber ?? "Text message" + case .totp: "Authenticator app" + } + } + + private func subtitle(_ hint: MFAHint?, _ state: MFAResolutionContentState) -> String { + switch hint { + case let .phone(_, _, phoneNumber): + state.isCodeSent + ? "Enter the 6-digit code we sent to \(phoneNumber ?? "your phone")." + : "Your account uses two-factor authentication. We'll text a code to \(phoneNumber ?? "your phone")." + case .totp: + "Enter the 6-digit code from your authenticator app." + case nil: + "Your account uses two-factor authentication." + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalComponents.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalComponents.swift new file mode 100644 index 00000000000..36b1d05b208 --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalComponents.swift @@ -0,0 +1,555 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import SwiftUI + +// The Professional theme's own layout: flat, left-aligned pages with labelled fields and grouped +// rows, instead of the shared layout the Figma themes use. It has no Figma frame; the values +// match the "Professional sign-in layout" mockups and ``DemoTheme/professional``. + +/// Professional colours, each with a light and a dark variant. +enum ProPalette { + static let background = Color(demoLight: 0xF5F6F8, dark: 0x0F1115) + static let surface = Color(demoLight: 0xFFFFFF, dark: 0x181B20) + static let field = Color(demoLight: 0xFFFFFF, dark: 0x0F1115) + static let text = Color(demoLight: 0x111418, dark: 0xF2F4F7) + static let muted = Color(demoLight: 0x5B6470, dark: 0x9AA3AE) + static let placeholder = Color(demoLight: 0x6B7480, dark: 0x7D8693) + static let border = Color(demoLight: 0xC9CFD6, dark: 0x3A414A) + static let hairline = Color(demoLight: 0xE3E6EA, dark: 0x2A2F36) + /// Links and text-only actions. + static let accent = Color(demoLight: 0x1F5EFF, dark: 0x4C7DFF) + /// Filled buttons: darker than ``accent`` in dark mode so white labels keep 4.5:1 contrast. + static let accentFill = Color(demoLight: 0x1F5EFF, dark: 0x2F62F0) + static let accentSoft = Color(demoLight: 0xEEF2FF, dark: 0x1C2540) + static let accentSoftText = Color(demoLight: 0x1F5EFF, dark: 0x9DB5FF) + static let tile = Color(demoLight: 0xEEF0F3, dark: 0x22262D) + static let danger = Color(demoLight: 0xB42318, dark: 0xF97066) + static let chevron = Color(demoLight: 0x9AA3AE, dark: 0x5B6470) +} + +/// The scaffold every Professional screen shares: a scrolling, left-aligned column with a header, +/// the screen's content and an optional footer. The footer sits at the bottom when the content is +/// short, and scrolls with it otherwise, so the keyboard never pushes it over a button. +struct ProPage: View { + /// `showsThemeMenu` is off for screens in their own sheet, such as reauthentication. + init(showsThemeMenu: Bool = true, + @ViewBuilder header: () -> Header, + @ViewBuilder content: () -> Content, + @ViewBuilder footer: () -> Footer = { EmptyView() }) { + self.showsThemeMenu = showsThemeMenu + self.header = header() + self.content = content() + self.footer = footer() + } + + private let showsThemeMenu: Bool + private let header: Header + private let content: Content + private let footer: Footer + + var body: some View { + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + header + content + .padding(.top, 28) + Spacer(minLength: 32) + footer + } + .frame(maxWidth: .infinity, alignment: .leading) + .demoContentWidth() + .padding(.top, 8) + .padding(.bottom, 16) + .frame(minHeight: proxy.size.height, alignment: .top) + } + .scrollBounceBehavior(.basedOnSize) + } + .foregroundStyle(ProPalette.text) + .background(ProPalette.background.ignoresSafeArea()) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsThemeMenu { + ToolbarItem(placement: .topBarLeading) { + DemoThemeMenu() + } + } + } + } +} + +/// A title and a one-line explanation, under an optional icon tile. +struct ProHeader: View { + var symbol: String? + let title: String + var subtitle: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let symbol { + ProIconTile(symbol: symbol, size: 44) + .padding(.bottom, 20) + } + Text(title) + .font(.system(size: 26, weight: .semibold)) + .tracking(-0.3) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("title") + if let subtitle { + Text(subtitle) + .font(.system(size: 15)) + .foregroundStyle(ProPalette.muted) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) + } + } + } +} + +/// An SF Symbol on a soft accent square. +struct ProIconTile: View { + let symbol: String + var size: CGFloat = 36 + var fill: Color = ProPalette.accentSoft + var tint: Color = ProPalette.accentSoftText + + var body: some View { + Image(systemName: symbol) + .font(.system(size: size * 0.45, weight: .medium)) + .foregroundStyle(tint) + .frame(width: size, height: size) + .background(fill, in: RoundedRectangle(cornerRadius: size * 0.28)) + .accessibilityHidden(true) + } +} + +/// A text field with its label above it. `accessory` sits on the label's row, such as a +/// "Forgot password?" link. +struct ProField: View { + let label: String + let placeholder: String + @Binding var text: String + var isSecure = false + var keyboard: UIKeyboardType = .default + var contentType: UITextContentType? + var identifier: String + @ViewBuilder var accessory: () -> Accessory + + @State private var isRevealed = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: 14, weight: .medium)) + Spacer(minLength: 8) + accessory() + } + HStack(spacing: 0) { + Group { + if isSecure, !isRevealed { + SecureField(text: $text) { placeholderText } + } else { + TextField(text: $text) { placeholderText } + } + } + .font(.system(size: 16)) + .keyboardType(keyboard) + .textContentType(contentType) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel(label) + .accessibilityIdentifier(identifier) + if isSecure { + Button { + isRevealed.toggle() + } label: { + Image(systemName: isRevealed ? "eye.slash" : "eye") + .foregroundStyle(ProPalette.muted) + .frame(width: 44, height: 44) + } + .accessibilityLabel(isRevealed ? "Hide password" : "Show password") + } + } + .padding(.leading, 14) + .padding(.trailing, isSecure ? 2 : 14) + .frame(height: 48) + .proFieldBox() + } + } + + private var placeholderText: Text { + Text(placeholder).foregroundStyle(ProPalette.placeholder) + } +} + +extension ProField where Accessory == EmptyView { + init(label: String, + placeholder: String, + text: Binding, + isSecure: Bool = false, + keyboard: UIKeyboardType = .default, + contentType: UITextContentType? = nil, + identifier: String) { + self.init( + label: label, + placeholder: placeholder, + text: text, + isSecure: isSecure, + keyboard: keyboard, + contentType: contentType, + identifier: identifier, + accessory: { EmptyView() } + ) + } +} + +extension View { + /// The Professional text field's box: field fill, 1-point border, 10-point corners. + func proFieldBox() -> some View { + let shape = RoundedRectangle(cornerRadius: 10) + return background(ProPalette.field, in: shape) + .overlay { shape.strokeBorder(ProPalette.border, lineWidth: 1) } + } +} + +/// The phone number field: the library's `CountrySelector` beside a number field. +struct ProPhoneField: View { + let phoneNumber: Binding + let country: Binding + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Phone number") + .font(.system(size: 14, weight: .medium)) + HStack(spacing: 10) { + CountrySelector(selectedCountry: country) + .tint(ProPalette.text) + .accessibilityIdentifier("country-selector") + Rectangle() + .fill(ProPalette.hairline) + .frame(width: 1, height: 24) + TextField(text: phoneNumber) { + Text("555 123 4567").foregroundStyle(ProPalette.placeholder) + } + .font(.system(size: 16)) + .keyboardType(.phonePad) + .textContentType(.telephoneNumber) + .accessibilityLabel("Phone number") + .accessibilityIdentifier("phone-number-field") + } + .padding(.horizontal, 14) + .frame(height: 48) + .proFieldBox() + } + } +} + +/// The full-width filled button. +struct ProPrimaryButton: View { + let title: String + var isLoading = false + var identifier: String + let action: () -> Void + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + Button(action: action) { + ZStack { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .opacity(isLoading ? 0 : 1) + if isLoading { + ProgressView().tint(.white) + } + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 52) + .background(ProPalette.accentFill, in: RoundedRectangle(cornerRadius: 10)) + .opacity(isEnabled ? 1 : 0.45) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +/// A full-width outlined button, for the sign-in providers. +struct ProOutlineButton: View { + let title: String + var identifier: String + @ViewBuilder let icon: () -> Icon + let action: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 10) + Button(action: action) { + HStack(spacing: 10) { + icon() + .frame(width: 20, height: 20) + Text(title) + .font(.system(size: 15, weight: .semibold)) + } + .foregroundStyle(ProPalette.text) + .frame(maxWidth: .infinity, minHeight: 48) + .background(ProPalette.surface, in: shape) + .overlay { shape.strokeBorder(ProPalette.border, lineWidth: 1) } + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +/// A text-only action in the accent colour, 44 points tall. +struct ProTextButton: View { + let title: String + var alignment: Alignment = .center + var identifier: String + let action: () -> Void + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(ProPalette.accent) + .opacity(isEnabled ? 1 : 0.5) + .frame(maxWidth: .infinity, minHeight: 44, alignment: alignment) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +/// "Prompt? Action", centred, with only the action tappable. +struct ProPromptLink: View { + let prompt: String + let action: String + var identifier: String + let perform: () -> Void + + var body: some View { + HStack(spacing: 4) { + Text(prompt) + .foregroundStyle(ProPalette.muted) + Button(action: perform) { + Text(action) + .fontWeight(.medium) + .foregroundStyle(ProPalette.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } + .font(.system(size: 15)) + .frame(maxWidth: .infinity) + } +} + +/// A rule with a short label in the middle. +struct ProDivider: View { + let label: String + + var body: some View { + HStack(spacing: 12) { + line + Text(label) + .font(.system(size: 13)) + .foregroundStyle(ProPalette.muted) + .fixedSize() + line + } + } + + private var line: some View { + Rectangle().fill(ProPalette.hairline).frame(height: 1) + } +} + +/// A content slot's `errorMessage`. +struct ProErrorText: View { + let message: String? + + var body: some View { + if let message { + Label { + Text(message) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.circle") + } + .font(.system(size: 14)) + .foregroundStyle(ProPalette.danger) + .accessibilityIdentifier("error-message") + } + } +} + +/// A small uppercase label above a ``ProGroup``. +struct ProSectionLabel: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 13, weight: .medium)) + .tracking(0.3) + .textCase(.uppercase) + .foregroundStyle(ProPalette.muted) + .padding(.horizontal, 4) + .padding(.bottom, 8) + } +} + +/// Grouped rows on a bordered card, like an iOS Settings section. Separate rows with +/// ``ProRowSeparator``. +struct ProGroup: View { + @ViewBuilder let content: () -> Content + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 12) + VStack(spacing: 0, content: content) + .background(ProPalette.surface, in: shape) + .clipShape(shape) + .overlay { shape.strokeBorder(ProPalette.hairline, lineWidth: 1) } + } +} + +/// The hairline between rows, inset past a row's icon. +struct ProRowSeparator: View { + var inset: CGFloat = 48 + + var body: some View { + Rectangle() + .fill(ProPalette.hairline) + .frame(height: 1) + .padding(.leading, inset) + } +} + +/// One tappable settings row: an optional icon, a title, an optional value and a chevron. +struct ProRow: View { + var symbol: String? + let title: String + var value: String? + var valueTint: Color = ProPalette.muted + var tint: Color = ProPalette.text + var showsChevron = true + var identifier: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + if let symbol { + Image(systemName: symbol) + .font(.system(size: 17)) + .frame(width: 20) + .accessibilityHidden(true) + } + Text(title) + .font(.system(size: 16, weight: symbol == nil ? .medium : .regular)) + .frame(maxWidth: .infinity, alignment: .leading) + if let value { + Text(value) + .font(.system(size: 15)) + .foregroundStyle(valueTint) + } + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ProPalette.chevron) + .accessibilityHidden(true) + } + } + .foregroundStyle(tint) + .padding(.horizontal, 16) + .frame(minHeight: 52) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} + +/// A selectable row in a ``ProGroup``: icon tile, title, description and a radio mark. +struct ProChoiceRow: View { + let symbol: String + let title: String + let detail: String + let isSelected: Bool + var identifier: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + ProIconTile( + symbol: symbol, + fill: isSelected ? ProPalette.accentSoft : ProPalette.tile, + tint: isSelected ? ProPalette.accentSoftText : ProPalette.text + ) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 16, weight: .medium)) + Text(detail) + .font(.system(size: 14)) + .foregroundStyle(ProPalette.muted) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? ProPalette.accent : ProPalette.border) + .accessibilityHidden(true) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier(identifier) + } +} + +/// The terms line for screens that create or sign in to an account, from the configured URLs. +struct ProLegalText: View { + var prefix = "By continuing, you agree to the" + + @Environment(AuthService.self) private var authService + + var body: some View { + let configuration = authService.configuration + if let tos = configuration.tosUrl, let privacy = configuration.privacyPolicyUrl, + let terms = try? AttributedString(markdown: """ + \(prefix) [Terms of Service](\(tos.absoluteString)) \ + and [Privacy Policy](\(privacy.absoluteString)). + """) { + Text(terms) + .font(.system(size: 13)) + .foregroundStyle(ProPalette.muted) + .tint(ProPalette.accent) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("terms") + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalSignInScreens.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalSignInScreens.swift new file mode 100644 index 00000000000..840c1b11f6c --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Examples/FullCustomization/ProfessionalSignInScreens.swift @@ -0,0 +1,409 @@ +// Copyright 2025 Google LLC +// +// 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. + +import FirebaseAuthSwiftUI +import SwiftUI + +/// The Professional first screen: the sign-in providers first, then the email form. Creating an +/// account is its own pushed screen (``ProSignUp``), reached through `goToSignUp`. +struct ProLanding: View { + let pickerState: AuthMethodPickerState + + @Environment(AuthService.self) private var authService + @State private var isShowingOtherProviders = false + @State private var pendingProvider: AuthProviderUI? + + private var google: AuthProviderUI? { + pickerState.providers.first { $0.id == "google.com" } + } + + private var phone: AuthProviderUI? { + pickerState.providers.first { $0.id == "phone" } + } + + private var otherProviders: [AuthProviderUI] { + pickerState.providers.filter { $0.id != "google.com" && $0.id != "phone" } + } + + var body: some View { + EmailAuthScreen(flow: .signIn) { state in + ProPage { + VStack(alignment: .leading, spacing: 28) { + HStack(spacing: 10) { + ProIconTile(symbol: "shield", size: 32, fill: ProPalette.accentFill, tint: .white) + Text("FirebaseUI Demo") + .font(.system(size: 15, weight: .semibold)) + } + ProHeader( + title: "Sign in to your account", + subtitle: "Welcome back. Choose how you'd like to continue." + ) + } + } content: { + VStack(alignment: .leading, spacing: 0) { + providers + if authService.emailPasswordSignInEnabled { + if !pickerState.providers.isEmpty { + ProDivider(label: "or continue with email") + .padding(.vertical, 22) + } + emailForm(state) + } + } + } footer: { + ProLegalText() + } + } + // Sign-in starts once the sheet is gone, so a provider that presents its own screen + // (such as an OAuth web view) isn't presented from a sheet that is animating away. + .sheet(isPresented: $isShowingOtherProviders, onDismiss: { + if let provider = pendingProvider { + pendingProvider = nil + pickerState.select(provider) + } + }) { + DemoOtherProvidersSheet(providers: otherProviders) { provider in + pendingProvider = provider + isShowingOtherProviders = false + } + .presentationDetents([.medium, .large]) + } + } + + private var providers: some View { + VStack(spacing: 10) { + if let google { + ProOutlineButton(title: "Continue with Google", identifier: "google-button") { + Image("FullCustomization/google-g") + } action: { + pickerState.select(google) + } + } + if let phone { + ProOutlineButton(title: "Continue with phone", identifier: "phone-button") { + Image(systemName: "phone") + } action: { + pickerState.select(phone) + } + } + if !otherProviders.isEmpty { + ProOutlineButton(title: "More sign-in options", identifier: "other-providers-button") { + Image(systemName: "ellipsis.circle") + } action: { + isShowingOtherProviders = true + } + } + } + } + + @ViewBuilder + private func emailForm(_ state: EmailAuthContentState) -> some View { + VStack(alignment: .leading, spacing: 16) { + ProField( + label: "Email", + placeholder: "name@company.com", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + ProField( + label: "Password", + placeholder: "Enter your password", + text: state.password, + isSecure: true, + contentType: .password, + identifier: "password-field" + ) { + Button("Forgot password?", action: state.goToPasswordRecovery) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(ProPalette.accent) + .buttonStyle(.plain) + .accessibilityIdentifier("forgot-password") + } + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Sign in", + isLoading: state.isLoading, + identifier: "sign-in-button", + action: state.signIn + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + if pickerState.isEmailLinkEnabled { + ProTextButton( + title: "Email me a sign-in link", + identifier: "email-link-button", + action: state.goToEmailLink + ) + .padding(.top, 4) + } + ProPromptLink( + prompt: "Don't have an account?", + action: "Create account", + identifier: "create-account-button", + perform: state.goToSignUp + ) + .padding(.top, 4) + } +} + +/// The pushed sign-up screen, on the `.signUp` route. +struct ProSignUp: View { + var body: some View { + EmailAuthScreen(flow: .signUp) { state in + ProPage { + ProHeader( + symbol: "person.badge.plus", + title: "Create your account", + subtitle: "Sign up with your email. It takes less than a minute." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + ProField( + label: "Email", + placeholder: "name@company.com", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + ProField( + label: "Password", + placeholder: "At least 6 characters", + text: state.password, + isSecure: true, + contentType: .newPassword, + identifier: "password-field" + ) + ProField( + label: "Confirm password", + placeholder: "Repeat your password", + text: state.confirmPassword, + isSecure: true, + contentType: .newPassword, + identifier: "confirm-password-field" + ) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Create account", + isLoading: state.isLoading, + identifier: "create-account-submit", + action: state.signUp + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + // With a fixed sign-up flow, switchFlow goes back to the sign-in screen. + ProPromptLink( + prompt: "Already have an account?", + action: "Sign in", + identifier: "sign-in-instead-button", + perform: state.switchFlow + ) + .padding(.top, 8) + } footer: { + ProLegalText(prefix: "By creating an account, you agree to the") + } + } + } +} + +struct ProPasswordRecovery: View { + var body: some View { + PasswordRecoveryScreen { state in + if state.didSend { + ProPage { + ProHeader( + symbol: "envelope.badge", + title: "Check your inbox", + subtitle: "We sent a link to reset your password to \(state.sentEmail)." + ) + } content: { + ProPrimaryButton( + title: "Back to sign in", + identifier: "back-to-sign-in-button", + action: state.dismissSuccess + ) + } + } else { + ProPage { + ProHeader( + symbol: "lock", + title: "Reset your password", + subtitle: "Enter the email you sign in with and we'll send you a link to choose a new password." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + ProField( + label: "Email", + placeholder: "name@company.com", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Send reset link", + isLoading: state.isLoading, + identifier: "send-reset-link-button", + action: state.sendResetLink + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + } + } + } + } +} + +struct ProEmailLink: View { + var body: some View { + EmailLinkScreen { state in + if state.didSend { + ProPage { + ProHeader( + symbol: "envelope.badge", + title: "Check your inbox", + subtitle: "We sent a sign-in link to \(state.email.wrappedValue). Open it on this device to finish signing in." + ) + } content: { + ProErrorText(message: state.errorMessage) + ProTextButton( + title: "Use a different email", + identifier: "different-email-button", + action: state.dismissAlert + ) + .disabled(state.isLoading) + } + } else { + ProPage { + ProHeader( + symbol: "envelope", + title: "Sign in with a link", + subtitle: "We'll email you a link that signs you in, no password needed." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + ProField( + label: "Email", + placeholder: "name@company.com", + text: state.email, + keyboard: .emailAddress, + contentType: .emailAddress, + identifier: "email-field" + ) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Send link", + isLoading: state.isLoading, + identifier: "send-login-link-button", + action: state.sendSignInLink + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + } + } + } + } +} + +struct ProEnterPhoneNumber: View { + var body: some View { + EnterPhoneNumberScreen { state in + ProPage { + ProHeader( + symbol: "phone", + title: "Sign in with your phone", + subtitle: "We'll text you a 6-digit code to confirm it's you. Message and data rates may apply." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + ProPhoneField(phoneNumber: state.phoneNumber, country: state.selectedCountry) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Send code", + isLoading: state.isLoading, + identifier: "send-code-button", + action: state.sendCode + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + } + } + } +} + +struct ProEnterVerificationCode: View { + let verificationID: String + let fullPhoneNumber: String + + var body: some View { + EnterVerificationCodeScreen( + verificationID: verificationID, + fullPhoneNumber: fullPhoneNumber + ) { state in + ProPage { + ProHeader( + symbol: "number", + title: "Enter the code", + subtitle: "We sent a 6-digit code to \(state.fullPhoneNumber)." + ) + } content: { + VStack(alignment: .leading, spacing: 16) { + ProField( + label: "Verification code", + placeholder: "6-digit code", + text: state.verificationCode, + keyboard: .numberPad, + contentType: .oneTimeCode, + identifier: "verification-code-field" + ) + ProErrorText(message: state.errorMessage) + ProPrimaryButton( + title: "Verify", + isLoading: state.isLoading, + identifier: "verify-button", + action: state.verify + ) + .disabled(!state.isValid) + .padding(.top, 4) + } + HStack { + ProTextButton( + title: state.resendCountdown > 0 + ? "Resend code in \(state.resendCountdown)s" + : "Resend code", + alignment: .leading, + identifier: "resend-code-button", + action: state.resendCode + ) + .disabled(state.resendCountdown > 0 || state.isLoading) + ProTextButton( + title: "Use a different number", + alignment: .trailing, + identifier: "change-number-button", + action: state.changeNumber + ) + } + .padding(.top, 4) + } + } + } +} diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Info.plist b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Info.plist index 972c78a5b8c..53436b39dfa 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Info.plist +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Info.plist @@ -43,6 +43,20 @@ fbapi fb-messenger-share-api + UIAppFonts + + BagelFatOne-400.ttf + ChivoMono-500.ttf + ChivoMono-700.ttf + JetBrainsMono-500.ttf + JetBrainsMono-700.ttf + Onest-500.ttf + Onest-700.ttf + Roboto-500.ttf + Roboto-600.ttf + Rowdies-400.ttf + Rowdies-700.ttf + UIBackgroundModes fetch