Section 10/171 menit

10. Integrasi dengan SwiftUI

10. Integrasi dengan SwiftUI

Login Screen dengan State Machine

swift
// MARK: - Complete Login Screen

enum LoginStep {
    case idle
    case authenticating
    case authenticated(User)
    case needsPasskeySetup   // user baru, belum punya passkey
    case error(String)
}

@MainActor
@Observable
final class CompleteLoginViewModel {
    var step: LoginStep = .idle
    var username = ""
    var password = ""
    var showPasswordField = false

    private let authManager = UnifiedAuthManager()
    private let registrationManager = PasskeyRegistrationManager()

    func loginWithPasskeyOrPassword() async {
        step = .authenticating
        do {
            let serverChallenge = try await AuthAPIClient.shared.beginPasskeyAuthentication()
            let result = try await authManager.authenticate(
                relyingPartyID: "yourdomain.com",
                challenge: Data(base64URLEncoded: serverChallenge.challenge)!
            )

            switch result {
            case .passkey(let response):
                let user = try await AuthAPIClient.shared.finishPasskeyAuthentication(
                    credentialID: response.credentialID.base64URLEncodedString(),
                    clientDataJSON: response.rawClientDataJSON.base64URLEncodedString(),
                    authenticatorData: response.rawAuthenticatorData.base64URLEncodedString(),
                    signature: response.signature.base64URLEncodedString(),
                    userID: response.userID?.base64URLEncodedString()
                )
                step = .authenticated(user)

            case .password(let user, let pass):
                // Login dengan password lama
                let loggedUser = try await authenticateWithPassword(username: user, password: pass)
                step = .authenticated(loggedUser)
                // Tawarkan upgrade ke passkey
                await offerPasskeyUpgrade(for: loggedUser)
            }
        } catch PasskeyError.userCancelled {
            step = .idle
        } catch {
            step = .error(error.localizedDescription)
        }
    }

    private func authenticateWithPassword(username: String, password: String) async throws -> User {
        // Legacy password auth
        return User(id: "123", name: username)
    }

    func offerPasskeyUpgrade(for user: User) async {
        // Tampilkan prompt "Buat Passkey untuk Login Lebih Cepat?"
        step = .needsPasskeySetup
    }

    func setupPasskey(for user: User) async {
        do {
            let serverChallenge = try await AuthAPIClient.shared.beginPasskeyRegistration(
                username: user.name
            )
            let challenge = PasskeyRegistrationChallenge(
                challenge: Data(base64URLEncoded: serverChallenge.challenge)!,
                userID: Data(serverChallenge.userID.utf8),
                userName: user.name,
                userDisplayName: user.name,
                relyingPartyID: "yourdomain.com",
                relyingPartyName: "Your App"
            )
            let response = try await registrationManager.registerPasskey(with: challenge)
            try await AuthAPIClient.shared.finishPasskeyRegistration(
                credentialID: response.credentialID.base64URLEncodedString(),
                clientDataJSON: response.rawClientDataJSON.base64URLEncodedString(),
                attestationObject: response.rawAttestationObject.base64URLEncodedString()
            )
            step = .authenticated(user)
        } catch {
            step = .authenticated(user)  // gagal setup passkey, tetap login
        }
    }
}

struct LoginScreen: View {
    @State private var viewModel = CompleteLoginViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                Image(systemName: "person.badge.key.fill")
                    .font(.system(size: 64))
                    .foregroundStyle(.blue)

                Text("Selamat Datang")
                    .font(.largeTitle.bold())

                switch viewModel.step {
                case .idle, .error:
                    loginButtons
                case .authenticating:
                    ProgressView("Memverifikasi...")
                case .authenticated(let user):
                    VStack {
                        Image(systemName: "checkmark.circle.fill")
                            .font(.largeTitle)
                            .foregroundStyle(.green)
                        Text("Login berhasil, \(user.name)!")
                    }
                case .needsPasskeySetup:
                    passkeyUpgradePrompt
                }

                if case .error(let message) = viewModel.step {
                    Text(message)
                        .foregroundStyle(.red)
                        .font(.caption)
                }
            }
            .padding()
            .navigationTitle("Login")
        }
    }

    @ViewBuilder
    private var loginButtons: some View {
        Button {
            Task { await viewModel.loginWithPasskeyOrPassword() }
        } label: {
            Label("Login dengan Face ID / Passkey", systemImage: "faceid")
                .frame(maxWidth: .infinity)
        }
        .buttonStyle(.borderedProminent)
        .controlSize(.large)

        Button("Login dengan Password") {
            viewModel.showPasswordField = true
        }
        .foregroundStyle(.secondary)
    }

    @ViewBuilder
    private var passkeyUpgradePrompt: some View {
        VStack(spacing: 16) {
            Text("Buat Passkey?")
                .font(.headline)
            Text("Login lebih cepat dan aman tanpa password di semua perangkat Apple Anda.")
                .multilineTextAlignment(.center)
                .foregroundStyle(.secondary)

            Button("Buat Passkey Sekarang") {
                if case .needsPasskeySetup = viewModel.step,
                   case .authenticated(let user) = viewModel.step {
                    Task { await viewModel.setupPasskey(for: user) }
                }
            }
            .buttonStyle(.borderedProminent)

            Button("Lewati") {
                // tetap authenticated
            }
            .foregroundStyle(.secondary)
        }
    }
}