import Foundation import Combine /// Single source of truth for "who is signed in" — every feature screen reads /// `AppState.shared` (injected via `.environmentObject`) rather than talking /// to `AuthAPI`/`KeychainTokenStore` directly, so there is exactly one place /// that decides what "signed in" means and exactly one place a 401 anywhere /// in the app routes back to the login screen. @MainActor final class AppState: ObservableObject { enum AuthPhase: Equatable { /// Checking the Keychain / pinging `/api/auth/session` on launch. case checking case signedOut case signedIn(User) } @Published private(set) var phase: AuthPhase = .checking @Published var lastError: String? private var cancellables = Set() init() { NotificationCenter.default.publisher(for: .sessionExpired) .sink { [weak self] _ in Task { @MainActor in self?.phase = .signedOut } } .store(in: &cancellables) } var currentUser: User? { if case .signedIn(let user) = phase { return user } return nil } /// Call once on app launch. If a token is already in the Keychain, this /// validates it against the server (a locally-stored token could have /// been revoked server-side, e.g. via "sign out everywhere") rather than /// trusting its mere presence. func bootstrap() async { guard KeychainTokenStore.shared.token != nil else { phase = .signedOut return } do { let response = try await AuthAPI.session() phase = response.user.map(AuthPhase.signedIn) ?? .signedOut } catch { phase = .signedOut } } func login(email: String, password: String) async { lastError = nil do { let response = try await AuthAPI.login(email: email, password: password) guard let token = response.token else { // Should never happen — X-Client: ios always yields a token // (see src/app/api/auth/login/route.ts) — but fail safe // rather than silently "succeeding" signed out. lastError = "Anmeldung fehlgeschlagen. Bitte erneut versuchen." return } KeychainTokenStore.shared.token = token // The login response only carries a small subset of the user // (see LoginUser) — immediately follow up with /session so the // rest of the app has the full profile (isPro, scan quota, ...). await bootstrap() } catch { lastError = error.localizedDescription } } /// Mirrors `login(email:password:)` exactly, just against /// `/api/auth/apple` instead — same token-storage/bootstrap follow-up, /// same fail-safe if a token somehow doesn't come back. `fullName` is /// only ever non-nil the first time this account authorizes the app /// (see `AuthAPI.appleSignIn`). func signInWithApple(identityToken: String, fullName: String?) async { lastError = nil do { let response = try await AuthAPI.appleSignIn(identityToken: identityToken, fullName: fullName) guard let token = response.token else { lastError = "Anmeldung mit Apple fehlgeschlagen. Bitte erneut versuchen." return } KeychainTokenStore.shared.token = token await bootstrap() } catch { lastError = error.localizedDescription } } /// Returns true when a confirmation mail was sent (the account still /// needs email verification before it can log in) — matches the web /// signup flow exactly, including its neutral response (see AuthAPI). func signup(name: String?, email: String, password: String) async -> Bool { lastError = nil do { _ = try await AuthAPI.signup(name: name, email: email, password: password) return true } catch { lastError = error.localizedDescription return false } } func logout() async { try? await AuthAPI.logout() KeychainTokenStore.shared.token = nil phase = .signedOut } }