// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // KAID.swift — connexion KA ID NATIVE : ASWebAuthenticationSession vers le hub // groupe-ka.com (SSO officiel de l'écosystème, client « ka-ios »), retour par // le scheme ka-ios://auth, puis ÉCHANGE VÉRIFIÉ côté serveur (api-ka valide la // signature du jeton — aucun secret dans l'app) qui renvoie le profil. import AuthenticationServices import SwiftUI struct KAIDProfile: Codable, Equatable { var kaID: String var name: String? var email: String? var picture: String? var roleLabel: String? var city: String? var bio: String? } @MainActor final class KAIDManager: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { static let shared = KAIDManager() @Published var profile: KAIDProfile? @Published var busy = false @Published var lastError: String? private let storeKey = "ka.id.profile" private var session: ASWebAuthenticationSession? override init() { super.init() if let data = UserDefaults.standard.data(forKey: storeKey), let p = try? JSONDecoder().decode(KAIDProfile.self, from: data) { profile = p } } func login() { guard !busy else { return } busy = true lastError = nil let state = UUID().uuidString var comps = URLComponents(string: "https://www.groupe-ka.com/sso/authorize")! comps.queryItems = [ .init(name: "client_id", value: "ka-ios"), .init(name: "redirect_uri", value: "ka-ios://auth/callback"), .init(name: "state", value: state), ] let s = ASWebAuthenticationSession(url: comps.url!, callbackURLScheme: "ka-ios") { [weak self] url, error in Task { @MainActor in guard let self else { return } defer { self.busy = false } guard error == nil, let url else { if let e = error as? ASWebAuthenticationSessionError, e.code == .canceledLogin { return } self.lastError = "Connexion annulée ou impossible." return } let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] guard items.first(where: { $0.name == "state" })?.value == state, let token = items.first(where: { $0.name == "ka_token" })?.value else { self.lastError = "Réponse du hub invalide." return } await self.exchange(token) } } s.presentationContextProvider = self s.prefersEphemeralWebBrowserSession = false // garde la session hub (Google/courriel) session = s s.start() } private func exchange(_ token: String) async { do { var req = URLRequest(url: URL(string: "https://www.api-ka.com/api/ios/auth/exchange")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: ["ka_token": token]) let (data, resp) = try await URLSession.shared.data(for: req) guard (resp as? HTTPURLResponse)?.statusCode == 200, let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any], let kaID = obj["ka_id"] as? String else { lastError = "Vérification du compte impossible." return } let hub = obj["profile"] as? [String: Any] let p = KAIDProfile( kaID: kaID, name: obj["name"] as? String ?? hub?["name"] as? String, email: obj["email"] as? String, picture: obj["picture"] as? String ?? hub?["picture"] as? String, roleLabel: hub?["role_label"] as? String, city: hub?["city"] as? String, bio: hub?["bio"] as? String ) profile = p if let d = try? JSONEncoder().encode(p) { UserDefaults.standard.set(d, forKey: storeKey) } Haptics.success() } catch { lastError = "Réseau indisponible — réessayez." } } func logout() { profile = nil UserDefaults.standard.removeObject(forKey: storeKey) Haptics.tap() } nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { MainActor.assumeIsolated { UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.keyWindow } .first ?? ASPresentationAnchor() } } } // MARK: - Carte de membre Groupe KA struct KAIDCard: View { let profile: KAIDProfile var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Groupe").font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(Color(hex: "#f5f3ee")) Text("KA").font(.system(.caption, design: .rounded).weight(.bold)) .foregroundStyle(KATheme.lime) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color(hex: "#f5f3ee").opacity(0.14), in: RoundedRectangle(cornerRadius: 6)) Spacer() Text("MEMBRE") .font(.system(size: 9, design: .monospaced).weight(.bold)) .foregroundStyle(KATheme.lime) } HStack(spacing: 12) { AsyncImage(url: profile.picture.flatMap(URL.init(string:))) { phase in if case .success(let img) = phase { img.resizable() } else { KATheme.lime.opacity(0.25).overlay( Text(String(profile.name?.prefix(1) ?? "K")).font(.title2.weight(.bold)).foregroundStyle(KATheme.lime)) } } .frame(width: 54, height: 54) .clipShape(Circle()) .overlay(Circle().strokeBorder(KATheme.lime, lineWidth: 1.6)) VStack(alignment: .leading, spacing: 2) { Text(profile.name ?? "Membre KA") .font(.headline).foregroundStyle(Color(hex: "#f5f3ee")) Text(profile.kaID) .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle(KATheme.lime) if let r = profile.roleLabel ?? profile.city { Text(r).font(.caption2).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.6)) } } Spacer() } Text("Un seul compte pour les 13 plateformes de l'écosystème.") .font(.caption2).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.55)) } .padding(16) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(KATheme.lime.opacity(0.5), lineWidth: 1.2)) } }