Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KAID.swift — connexion KA ID NATIVE : ASWebAuthenticationSession vers le hub3// groupe-ka.com (SSO officiel de l'écosystème, client « ka-ios »), retour par4// le scheme ka-ios://auth, puis ÉCHANGE VÉRIFIÉ côté serveur (api-ka valide la5// signature du jeton — aucun secret dans l'app) qui renvoie le profil.6import AuthenticationServices7import SwiftUI89struct KAIDProfile: Codable, Equatable {10 var kaID: String11 var name: String?12 var email: String?13 var picture: String?14 var roleLabel: String?15 var city: String?16 var bio: String?17}1819@MainActor20final class KAIDManager: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding {21 static let shared = KAIDManager()22 @Published var profile: KAIDProfile?23 @Published var busy = false24 @Published var lastError: String?2526 private let storeKey = "ka.id.profile"27 private var session: ASWebAuthenticationSession?2829 override init() {30 super.init()31 if let data = UserDefaults.standard.data(forKey: storeKey),32 let p = try? JSONDecoder().decode(KAIDProfile.self, from: data) {33 profile = p34 }35 }3637 func login() {38 guard !busy else { return }39 busy = true40 lastError = nil41 let state = UUID().uuidString42 var comps = URLComponents(string: "https://www.groupe-ka.com/sso/authorize")!43 comps.queryItems = [44 .init(name: "client_id", value: "ka-ios"),45 .init(name: "redirect_uri", value: "ka-ios://auth/callback"),46 .init(name: "state", value: state),47 ]48 let s = ASWebAuthenticationSession(url: comps.url!, callbackURLScheme: "ka-ios") { [weak self] url, error in49 Task { @MainActor in50 guard let self else { return }51 defer { self.busy = false }52 guard error == nil, let url else {53 if let e = error as? ASWebAuthenticationSessionError, e.code == .canceledLogin { return }54 self.lastError = "Connexion annulée ou impossible."55 return56 }57 let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []58 guard items.first(where: { $0.name == "state" })?.value == state,59 let token = items.first(where: { $0.name == "ka_token" })?.value else {60 self.lastError = "Réponse du hub invalide."61 return62 }63 await self.exchange(token)64 }65 }66 s.presentationContextProvider = self67 s.prefersEphemeralWebBrowserSession = false // garde la session hub (Google/courriel)68 session = s69 s.start()70 }7172 private func exchange(_ token: String) async {73 do {74 var req = URLRequest(url: URL(string: "https://www.api-ka.com/api/ios/auth/exchange")!)75 req.httpMethod = "POST"76 req.setValue("application/json", forHTTPHeaderField: "Content-Type")77 req.httpBody = try JSONSerialization.data(withJSONObject: ["ka_token": token])78 let (data, resp) = try await URLSession.shared.data(for: req)79 guard (resp as? HTTPURLResponse)?.statusCode == 200,80 let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any],81 let kaID = obj["ka_id"] as? String else {82 lastError = "Vérification du compte impossible."83 return84 }85 let hub = obj["profile"] as? [String: Any]86 let p = KAIDProfile(87 kaID: kaID,88 name: obj["name"] as? String ?? hub?["name"] as? String,89 email: obj["email"] as? String,90 picture: obj["picture"] as? String ?? hub?["picture"] as? String,91 roleLabel: hub?["role_label"] as? String,92 city: hub?["city"] as? String,93 bio: hub?["bio"] as? String94 )95 profile = p96 if let d = try? JSONEncoder().encode(p) {97 UserDefaults.standard.set(d, forKey: storeKey)98 }99 Haptics.success()100 } catch {101 lastError = "Réseau indisponible — réessayez."102 }103 }104105 func logout() {106 profile = nil107 UserDefaults.standard.removeObject(forKey: storeKey)108 Haptics.tap()109 }110111 nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {112 MainActor.assumeIsolated {113 UIApplication.shared.connectedScenes114 .compactMap { ($0 as? UIWindowScene)?.keyWindow }115 .first ?? ASPresentationAnchor()116 }117 }118}119120// MARK: - Carte de membre Groupe KA121122struct KAIDCard: View {123 let profile: KAIDProfile124 var body: some View {125 VStack(alignment: .leading, spacing: 10) {126 HStack {127 Text("Groupe").font(.system(.subheadline, design: .rounded).weight(.bold))128 .foregroundStyle(Color(hex: "#f5f3ee"))129 Text("KA").font(.system(.caption, design: .rounded).weight(.bold))130 .foregroundStyle(KATheme.lime)131 .padding(.horizontal, 6).padding(.vertical, 2)132 .background(Color(hex: "#f5f3ee").opacity(0.14), in: RoundedRectangle(cornerRadius: 6))133 Spacer()134 Text("MEMBRE")135 .font(.system(size: 9, design: .monospaced).weight(.bold))136 .foregroundStyle(KATheme.lime)137 }138 HStack(spacing: 12) {139 AsyncImage(url: profile.picture.flatMap(URL.init(string:))) { phase in140 if case .success(let img) = phase { img.resizable() }141 else { KATheme.lime.opacity(0.25).overlay(142 Text(String(profile.name?.prefix(1) ?? "K")).font(.title2.weight(.bold)).foregroundStyle(KATheme.lime)) }143 }144 .frame(width: 54, height: 54)145 .clipShape(Circle())146 .overlay(Circle().strokeBorder(KATheme.lime, lineWidth: 1.6))147 VStack(alignment: .leading, spacing: 2) {148 Text(profile.name ?? "Membre KA")149 .font(.headline).foregroundStyle(Color(hex: "#f5f3ee"))150 Text(profile.kaID)151 .font(.system(.caption, design: .monospaced).weight(.bold))152 .foregroundStyle(KATheme.lime)153 if let r = profile.roleLabel ?? profile.city {154 Text(r).font(.caption2).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.6))155 }156 }157 Spacer()158 }159 Text("Un seul compte pour les 13 plateformes de l'écosystème.")160 .font(.caption2).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.55))161 }162 .padding(16)163 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 16, style: .continuous))164 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)165 .strokeBorder(KATheme.lime.opacity(0.5), lineWidth: 1.2))166 }167}168