learning_ai_common_plat/packages/swift-platform-sdk/Sources/BLAuthUI.swift

741 lines
25 KiB
Swift

// 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))
}
}