Files
scan-receipts/app/ios/ScanReceipts/Features/Auth/AppleSignInButton.swift

87 lines
3.9 KiB
Swift

import SwiftUI
import AuthenticationServices
import Foundation // PersonNameComponentsFormatter
/// "Mit Apple anmelden" placed below the email/password form on
/// `LoginView`. Wired to the real backend: `POST /api/auth/apple`
/// (`src/app/api/auth/apple/route.ts`) verifies the identity token Apple
/// hands back and returns a session exactly like `/api/auth/login` does
/// see `AuthAPI.appleSignIn` / `AppState.signInWithApple`.
struct AppleSignInButton: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var appState: AppState
@State private var isSigningIn = false
/// Local, Apple-specific failures (token decode, user cancelled) kept
/// separate from `appState.lastError` so a cancelled Apple sheet doesn't
/// show a stale email/password error underneath it, or vice versa.
@State private var localErrorMessage: String?
var body: some View {
VStack(spacing: ZenithSpacing.unit * 2) {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
handle(result)
}
// Apple's Human Interface Guidelines fix this button's own shape
// and colors not overridable to match Zenith Silver's 0px
// corners, and Apple explicitly does not allow restyling it.
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(maxWidth: .infinity, minHeight: 50)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.disabled(isSigningIn)
.overlay {
if isSigningIn {
ProgressView().tint(.white)
}
}
// A backend-side failure (invalid token, server error, ...) sets
// `appState.lastError`, which `LoginView` already renders above
// this button showing it a second time here would just
// duplicate the same message. Only a LOCAL, pre-network failure
// (decode error, user cancelled) is shown here.
if let localErrorMessage {
Text(localErrorMessage).zenithBodySmStyle(color: .zenithError)
}
}
}
private func handle(_ result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let identityToken = String(data: tokenData, encoding: .utf8) else {
localErrorMessage = "Apple-Anmeldung fehlgeschlagen. Bitte erneut versuchen."
return
}
localErrorMessage = nil
// Only present on the account's very first authorization with
// this app nil on every later sign-in, which is expected and
// fine (AuthAPI.appleSignIn/the backend both treat it as optional).
let fullName = credential.fullName.flatMap { components -> String? in
let formatted = PersonNameComponentsFormatter.localizedString(from: components, style: .default)
return formatted.isEmpty ? nil : formatted
}
isSigningIn = true
Task {
await appState.signInWithApple(identityToken: identityToken, fullName: fullName)
isSigningIn = false
}
case .failure(let error):
// The user cancelling the Apple Sign-In sheet also lands here
// (ASAuthorizationError.canceled) that's expected, not a
// failure worth surfacing as an error.
if (error as? ASAuthorizationError)?.code == .canceled {
localErrorMessage = nil
} else {
localErrorMessage = error.localizedDescription
}
}
}
}