// ── Auth UI Kit ───────────────────────────────────────────── // Reusable SwiftUI auth views for all ByteLyst iOS/macOS apps. // BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet. // Themed via @Environment injection — always matches host product. import SwiftUI import os #if canImport(AuthenticationServices) import AuthenticationServices #endif private let logger = Logger(subsystem: "com.bytelyst.platform", category: "BLAuthUI") // MARK: - Auth UI Configuration /// Configuration for BLAuthUI views — passed via Environment. public struct BLAuthUIConfig { public let productName: String public let accentColor: Color public let backgroundColor: Color public let textColor: Color public let secondaryTextColor: Color public let cardColor: Color public let enabledProviders: [BLAuthUIProvider] public init( productName: String = "ByteLyst", accentColor: Color = .blue, backgroundColor: Color = Color(.systemBackground), textColor: Color = .primary, secondaryTextColor: Color = .secondary, cardColor: Color = Color(.secondarySystemBackground), enabledProviders: [BLAuthUIProvider] = [.google, .apple] ) { self.productName = productName self.accentColor = accentColor self.backgroundColor = backgroundColor self.textColor = textColor self.secondaryTextColor = secondaryTextColor self.cardColor = cardColor self.enabledProviders = enabledProviders } } /// OAuth providers supported by BLAuthUI. public enum BLAuthUIProvider: String, CaseIterable, Sendable { case google case microsoft case apple } // MARK: - Environment Key private struct BLAuthUIConfigKey: EnvironmentKey { static let defaultValue = BLAuthUIConfig() } extension EnvironmentValues { public var blAuthUIConfig: BLAuthUIConfig { get { self[BLAuthUIConfigKey.self] } set { self[BLAuthUIConfigKey.self] = newValue } } } // MARK: - BLLoginView /// Full login view with email/password + social buttons + passkey option. /// Host product injects theme via `.environment(\.blAuthUIConfig, config)`. public struct BLLoginView: View { @Environment(\.blAuthUIConfig) private var config @State private var email = "" @State private var password = "" @State private var isLoading = false @State private var errorMessage: String? /// Called with email + password when user taps Sign In. public var onLogin: (String, String) async throws -> Void /// Called with provider name when user taps a social button. public var onSocialLogin: (BLAuthUIProvider) -> Void /// Called when user taps "Use Passkey". public var onPasskeyLogin: (() -> Void)? /// Called when user taps "Forgot Password?". public var onForgotPassword: (() -> Void)? /// Called when user taps "Create Account". public var onCreateAccount: (() -> Void)? public init( onLogin: @escaping (String, String) async throws -> Void, onSocialLogin: @escaping (BLAuthUIProvider) -> Void, onPasskeyLogin: (() -> Void)? = nil, onForgotPassword: (() -> Void)? = nil, onCreateAccount: (() -> Void)? = nil ) { self.onLogin = onLogin self.onSocialLogin = onSocialLogin self.onPasskeyLogin = onPasskeyLogin self.onForgotPassword = onForgotPassword self.onCreateAccount = onCreateAccount } public var body: some View { ScrollView { VStack(spacing: 24) { // Header VStack(spacing: 8) { Text("Sign in to \(config.productName)") .font(.title2.bold()) .foregroundColor(config.textColor) Text("Welcome back") .font(.subheadline) .foregroundColor(config.secondaryTextColor) } .padding(.top, 40) // Social Buttons if !config.enabledProviders.isEmpty { VStack(spacing: 12) { ForEach(config.enabledProviders, id: \.self) { provider in socialButton(for: provider) } } dividerRow } // Email / Password VStack(spacing: 16) { TextField("Email", text: $email) .textFieldStyle(.roundedBorder) .textContentType(.emailAddress) .keyboardType(.emailAddress) .autocapitalization(.none) .disableAutocorrection(true) SecureField("Password", text: $password) .textFieldStyle(.roundedBorder) .textContentType(.password) } // Error if let errorMessage { Text(errorMessage) .font(.caption) .foregroundColor(.red) .multilineTextAlignment(.center) } // Sign In Button Button { Task { await performLogin() } } label: { HStack { if isLoading { ProgressView() .tint(.white) } Text("Sign In") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.accentColor) .foregroundColor(.white) .cornerRadius(10) } .disabled(email.isEmpty || password.isEmpty || isLoading) // Passkey if let onPasskeyLogin { Button { logger.debug("Passkey login tapped") onPasskeyLogin() } label: { HStack { Image(systemName: "person.badge.key.fill") Text("Sign in with Passkey") } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.cardColor) .foregroundColor(config.textColor) .cornerRadius(10) } } // Forgot Password / Create Account VStack(spacing: 12) { if let onForgotPassword { Button("Forgot Password?") { onForgotPassword() } .font(.subheadline) .foregroundColor(config.accentColor) } if let onCreateAccount { HStack { Text("Don't have an account?") .foregroundColor(config.secondaryTextColor) Button("Create Account") { onCreateAccount() } .foregroundColor(config.accentColor) } .font(.subheadline) } } } .padding(.horizontal, 24) } .background(config.backgroundColor.ignoresSafeArea()) } private func performLogin() async { isLoading = true errorMessage = nil do { logger.debug("Attempting email/password login for \(email)") try await onLogin(email, password) } catch let error as BLAuthError { // MFA required is not an error for the user — handled upstream logger.info("MFA required for \(email)") _ = error // Suppress unused warning } catch { logger.error("Login failed: \(error.localizedDescription)") errorMessage = error.localizedDescription } isLoading = false } @ViewBuilder private func socialButton(for provider: BLAuthUIProvider) -> some View { Button { logger.debug("Social login: \(provider.rawValue)") onSocialLogin(provider) } label: { HStack { providerIcon(for: provider) Text("Continue with \(providerDisplayName(provider))") .fontWeight(.medium) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.cardColor) .foregroundColor(config.textColor) .cornerRadius(10) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.gray.opacity(0.3), lineWidth: 1) ) } } @ViewBuilder private func providerIcon(for provider: BLAuthUIProvider) -> some View { switch provider { case .google: Image(systemName: "globe") case .microsoft: Image(systemName: "building.2") case .apple: Image(systemName: "applelogo") } } private func providerDisplayName(_ provider: BLAuthUIProvider) -> String { switch provider { case .google: return "Google" case .microsoft: return "Microsoft" case .apple: return "Apple" } } private var dividerRow: some View { HStack { Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) Text("or") .font(.caption) .foregroundColor(config.secondaryTextColor) Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) } } } // MARK: - BLMfaChallengeView /// 6-digit TOTP code entry with countdown and recovery code fallback. public struct BLMfaChallengeView: View { @Environment(\.blAuthUIConfig) private var config @State private var code = "" @State private var isLoading = false @State private var errorMessage: String? @State private var showRecovery = false public let challenge: BLMfaChallenge public var onVerify: (String, String, String) async throws -> Void public var onCancel: (() -> Void)? public init( challenge: BLMfaChallenge, onVerify: @escaping (String, String, String) async throws -> Void, onCancel: (() -> Void)? = nil ) { self.challenge = challenge self.onVerify = onVerify self.onCancel = onCancel } public var body: some View { VStack(spacing: 24) { // Header VStack(spacing: 8) { Image(systemName: "lock.shield.fill") .font(.system(size: 48)) .foregroundColor(config.accentColor) Text("Two-Factor Authentication") .font(.title3.bold()) .foregroundColor(config.textColor) Text(showRecovery ? "Enter a recovery code" : "Enter the 6-digit code from your authenticator app") .font(.subheadline) .foregroundColor(config.secondaryTextColor) .multilineTextAlignment(.center) } // Code Input TextField(showRecovery ? "Recovery Code" : "000000", text: $code) .textFieldStyle(.roundedBorder) .keyboardType(showRecovery ? .default : .numberPad) .multilineTextAlignment(.center) .font(.title2.monospaced()) .frame(maxWidth: 200) // Error if let errorMessage { Text(errorMessage) .font(.caption) .foregroundColor(.red) } // Verify Button Button { Task { await verify() } } label: { HStack { if isLoading { ProgressView().tint(.white) } Text("Verify") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.accentColor) .foregroundColor(.white) .cornerRadius(10) } .disabled(code.isEmpty || isLoading) // Toggle recovery / cancel VStack(spacing: 12) { Button(showRecovery ? "Use authenticator code" : "Use recovery code") { showRecovery.toggle() code = "" errorMessage = nil } .font(.subheadline) .foregroundColor(config.accentColor) if let onCancel { Button("Cancel") { onCancel() } .font(.subheadline) .foregroundColor(config.secondaryTextColor) } } } .padding(24) .background(config.backgroundColor) } private func verify() async { isLoading = true errorMessage = nil let method = showRecovery ? "recovery" : "totp" do { logger.debug("Verifying MFA: method=\(method)") try await onVerify(challenge.mfaChallenge, code, method) } catch { logger.error("MFA verify failed: \(error.localizedDescription)") errorMessage = error.localizedDescription code = "" } isLoading = false } } // MARK: - BLPasskeyView /// Passkey prompt with biometric hint text. /// Triggers ASAuthorizationController for platform passkey authentication. public struct BLPasskeyView: View { @Environment(\.blAuthUIConfig) private var config @State private var isLoading = false @State private var errorMessage: String? public var onAuthenticate: () async throws -> Void public var onCancel: (() -> Void)? public init( onAuthenticate: @escaping () async throws -> Void, onCancel: (() -> Void)? = nil ) { self.onAuthenticate = onAuthenticate self.onCancel = onCancel } public var body: some View { VStack(spacing: 24) { VStack(spacing: 12) { Image(systemName: "person.badge.key.fill") .font(.system(size: 48)) .foregroundColor(config.accentColor) Text("Sign in with Passkey") .font(.title3.bold()) .foregroundColor(config.textColor) Text("Use Face ID, Touch ID, or your security key to sign in") .font(.subheadline) .foregroundColor(config.secondaryTextColor) .multilineTextAlignment(.center) } if let errorMessage { Text(errorMessage) .font(.caption) .foregroundColor(.red) } Button { Task { await authenticate() } } label: { HStack { if isLoading { ProgressView().tint(.white) } Image(systemName: "faceid") Text("Continue") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.accentColor) .foregroundColor(.white) .cornerRadius(10) } .disabled(isLoading) if let onCancel { Button("Use another method") { onCancel() } .font(.subheadline) .foregroundColor(config.accentColor) } } .padding(24) .background(config.backgroundColor) } private func authenticate() async { isLoading = true errorMessage = nil do { logger.debug("Starting passkey authentication") try await onAuthenticate() } catch { logger.error("Passkey auth failed: \(error.localizedDescription)") errorMessage = error.localizedDescription } isLoading = false } } // MARK: - BLStepUpSheet /// Re-authentication sheet for sensitive operations. /// Supports password re-entry or biometric confirmation. public struct BLStepUpSheet: View { @Environment(\.blAuthUIConfig) private var config @Environment(\.dismiss) private var dismiss @State private var password = "" @State private var isLoading = false @State private var errorMessage: String? public let reason: String public var onStepUp: (String, String) async throws -> String public var onComplete: (String) -> Void public init( reason: String = "This action requires re-authentication", onStepUp: @escaping (String, String) async throws -> String, onComplete: @escaping (String) -> Void ) { self.reason = reason self.onStepUp = onStepUp self.onComplete = onComplete } public var body: some View { NavigationStack { VStack(spacing: 24) { VStack(spacing: 8) { Image(systemName: "lock.fill") .font(.system(size: 36)) .foregroundColor(config.accentColor) Text("Confirm Your Identity") .font(.title3.bold()) .foregroundColor(config.textColor) Text(reason) .font(.subheadline) .foregroundColor(config.secondaryTextColor) .multilineTextAlignment(.center) } SecureField("Password", text: $password) .textFieldStyle(.roundedBorder) .textContentType(.password) if let errorMessage { Text(errorMessage) .font(.caption) .foregroundColor(.red) } #if canImport(LocalAuthentication) Button { Task { await biometricStepUp() } } label: { HStack { Image(systemName: "faceid") Text("Use Biometrics") .fontWeight(.medium) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.cardColor) .foregroundColor(config.textColor) .cornerRadius(10) } #endif Button { Task { await passwordStepUp() } } label: { HStack { if isLoading { ProgressView().tint(.white) } Text("Confirm") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(.vertical, 14) .background(config.accentColor) .foregroundColor(.white) .cornerRadius(10) } .disabled(password.isEmpty || isLoading) Spacer() } .padding(24) .background(config.backgroundColor) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } private func passwordStepUp() async { isLoading = true errorMessage = nil do { logger.debug("Step-up: password method") let token = try await onStepUp("password", password) onComplete(token) dismiss() } catch { logger.error("Step-up failed: \(error.localizedDescription)") errorMessage = error.localizedDescription } isLoading = false } #if canImport(LocalAuthentication) private func biometricStepUp() async { let success = await BLBiometricAuth.authenticate(reason: "Confirm your identity") if success { isLoading = true errorMessage = nil do { logger.debug("Step-up: biometric method") let token = try await onStepUp("biometric", "biometric_verified") onComplete(token) dismiss() } catch { logger.error("Biometric step-up failed: \(error.localizedDescription)") errorMessage = error.localizedDescription } isLoading = false } else { errorMessage = "Biometric authentication failed" } } #endif } // ── BLDeviceListView ──────────────────────────────────────── /// Device management view — list trusted/remembered devices, revoke trust. /// Mirrors the Kotlin `BLDeviceListScreen` for platform parity. /// /// - Parameters: /// - devices: List of devices from `BLAuthClient.listDevices()`. /// - onRevokeDevice: Called with device ID when user revokes a device. /// - onRevokeAll: Called when user revokes all devices. `nil` hides the button. /// - isLoading: Whether data is loading. public struct BLDeviceListView: View { public let devices: [BLDevice] public let onRevokeDevice: (String) -> Void public var onRevokeAll: (() -> Void)? public var isLoading: Bool = false public init( devices: [BLDevice], onRevokeDevice: @escaping (String) -> Void, onRevokeAll: (() -> Void)? = nil, isLoading: Bool = false ) { self.devices = devices self.onRevokeDevice = onRevokeDevice self.onRevokeAll = onRevokeAll self.isLoading = isLoading } public var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { HStack { Text("Your Devices") .font(.title2.bold()) Spacer() if let onRevokeAll, !devices.isEmpty { Button(role: .destructive, action: onRevokeAll) { Text("Revoke All") } } } if isLoading { HStack { Spacer() ProgressView() Spacer() } .padding(.vertical, 32) } else if devices.isEmpty { Text("No devices found") .foregroundStyle(.secondary) .padding(.vertical, 16) } else { ForEach(devices, id: \.id) { device in DeviceCardView(device: device) { onRevokeDevice(device.id) } } } } .padding() } } } /// Individual device card within BLDeviceListView. private struct DeviceCardView: View { let device: BLDevice let onRevoke: () -> Void private var platformIcon: String { switch device.platform { case "ios": return "iphone" case "android": return "apps.iphone" case "macos": return "laptopcomputer" case "windows", "linux": return "desktopcomputer" default: return "display" } } private var trustColor: Color { switch device.trustLevel { case "trusted": return .blue case "remembered": return .green default: return .secondary } } var body: some View { HStack(spacing: 12) { Image(systemName: platformIcon) .font(.title2) .foregroundStyle(trustColor) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(device.name) .font(.body) Text("\(device.trustLevel) · \(device.platform)") .font(.caption) .foregroundStyle(.secondary) } Spacer() if device.trustLevel == "trusted" || device.trustLevel == "remembered" { Button(role: .destructive, action: onRevoke) { Image(systemName: "xmark.circle.fill") .foregroundStyle(.red) } .buttonStyle(.plain) } } .padding() .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }