fix
This commit is contained in:
73
ios/AiCard/AiCard/Models/AIModels.swift
Normal file
73
ios/AiCard/AiCard/Models/AIModels.swift
Normal file
@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - AI分析関連モデル
|
||||
|
||||
struct CollectionAnalysis: Codable {
|
||||
let totalCards: Int
|
||||
let uniqueCards: Int
|
||||
let rarityDistribution: [String: Int]
|
||||
let collectionScore: Double
|
||||
let recommendations: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalCards = "total_cards"
|
||||
case uniqueCards = "unique_cards"
|
||||
case rarityDistribution = "rarity_distribution"
|
||||
case collectionScore = "collection_score"
|
||||
case recommendations
|
||||
}
|
||||
}
|
||||
|
||||
struct GachaStats: Codable {
|
||||
let totalDraws: Int
|
||||
let cardsByRarity: [String: Int]
|
||||
let successRates: [String: Double]
|
||||
let recentActivity: [GachaActivity]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalDraws = "total_draws"
|
||||
case cardsByRarity = "cards_by_rarity"
|
||||
case successRates = "success_rates"
|
||||
case recentActivity = "recent_activity"
|
||||
}
|
||||
}
|
||||
|
||||
struct GachaActivity: Codable {
|
||||
let timestamp: String
|
||||
let userDid: String
|
||||
let cardName: String
|
||||
let rarity: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case timestamp
|
||||
case userDid = "user_did"
|
||||
case cardName = "card_name"
|
||||
case rarity
|
||||
}
|
||||
}
|
||||
|
||||
struct UniqueRegistry: Codable {
|
||||
let registeredCards: [String: String]
|
||||
let totalUnique: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case registeredCards = "registered_cards"
|
||||
case totalUnique = "total_unique"
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemStatus: Codable {
|
||||
let status: String
|
||||
let mcpEnabled: Bool
|
||||
let mcpEndpoint: String?
|
||||
let databaseConnected: Bool
|
||||
let aiGptConnected: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case mcpEnabled = "mcp_enabled"
|
||||
case mcpEndpoint = "mcp_endpoint"
|
||||
case databaseConnected = "database_connected"
|
||||
case aiGptConnected = "ai_gpt_connected"
|
||||
}
|
||||
}
|
@ -9,13 +9,21 @@ enum APIError: Error {
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
// MCP Server response format
|
||||
struct MCPResponse<T: Decodable>: Decodable {
|
||||
let data: T?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
class APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
#if DEBUG
|
||||
private let baseURL = "http://localhost:8000/api/v1"
|
||||
private let baseURL = "http://localhost:8000/api/v1" // ai.card direct access
|
||||
private let aiGptBaseURL = "http://localhost:8001" // ai.gpt MCP server (optional)
|
||||
#else
|
||||
private let baseURL = "https://api.card.syui.ai/api/v1"
|
||||
private let baseURL = "https://api.card.syui.ai/api/v1" // ai.card direct access
|
||||
private let aiGptBaseURL = "https://ai.gpt.syui.ai" // ai.gpt MCP server (optional)
|
||||
#endif
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@ -27,10 +35,63 @@ class APIClient {
|
||||
set { UserDefaults.standard.set(newValue, forKey: "authToken") }
|
||||
}
|
||||
|
||||
// ai.gpt MCP経由でのリクエスト(推奨)
|
||||
private func mcpRequest<T: Decodable>(_ endpoint: String,
|
||||
parameters: [String: Any] = [:]) -> AnyPublisher<T, APIError> {
|
||||
guard let url = URL(string: "\(aiGptBaseURL)\(endpoint)") else {
|
||||
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
components?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
|
||||
|
||||
guard let finalURL = components?.url else {
|
||||
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var request = URLRequest(url: finalURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
return URLSession.shared.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response in
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError("Invalid response")
|
||||
}
|
||||
|
||||
if !(200...299).contains(httpResponse.statusCode) {
|
||||
throw APIError.networkError("MCP Server error: \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
.decode(type: MCPResponse<T>.self, decoder: JSONDecoder())
|
||||
.tryMap { mcpResponse in
|
||||
if let data = mcpResponse.data {
|
||||
return data
|
||||
} else if let error = mcpResponse.error {
|
||||
throw APIError.networkError(error)
|
||||
} else {
|
||||
throw APIError.networkError("Invalid MCP response")
|
||||
}
|
||||
}
|
||||
.mapError { error in
|
||||
if error is DecodingError {
|
||||
return APIError.decodingError
|
||||
} else if let apiError = error as? APIError {
|
||||
return apiError
|
||||
} else {
|
||||
return APIError.networkError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// ai.card直接リクエスト(メイン)
|
||||
private func request<T: Decodable>(_ endpoint: String,
|
||||
method: String = "GET",
|
||||
body: Data? = nil,
|
||||
authenticated: Bool = true) -> AnyPublisher<T, APIError> {
|
||||
method: String = "GET",
|
||||
body: Data? = nil,
|
||||
authenticated: Bool = true) -> AnyPublisher<T, APIError> {
|
||||
guard let url = URL(string: "\(baseURL)\(endpoint)") else {
|
||||
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
|
||||
}
|
||||
@ -104,7 +165,7 @@ class APIClient {
|
||||
request("/auth/verify")
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
// MARK: - Cards (ai.card直接アクセス)
|
||||
|
||||
func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher<CardDrawResult, APIError> {
|
||||
let body = try? JSONEncoder().encode([
|
||||
@ -116,10 +177,61 @@ class APIClient {
|
||||
}
|
||||
|
||||
func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> {
|
||||
request("/cards/user/\(userDid)")
|
||||
return request("/cards/user/\(userDid)")
|
||||
}
|
||||
|
||||
func getCardDetails(cardId: Int) -> AnyPublisher<Card, APIError> {
|
||||
return request("/cards/\(cardId)")
|
||||
}
|
||||
|
||||
func getGachaStats() -> AnyPublisher<GachaStats, APIError> {
|
||||
return request("/cards/stats")
|
||||
}
|
||||
|
||||
func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> {
|
||||
request("/cards/unique")
|
||||
return request("/cards/unique")
|
||||
}
|
||||
|
||||
func getSystemStatus() -> AnyPublisher<[String: Any], APIError> {
|
||||
return request("/health")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI Enhanced API (Optional ai.gpt integration)
|
||||
|
||||
extension APIClient {
|
||||
|
||||
func analyzeCollection(userDid: String) -> AnyPublisher<CollectionAnalysis, APIError> {
|
||||
let parameters: [String: Any] = [
|
||||
"did": userDid
|
||||
]
|
||||
|
||||
return mcpRequest("/card_analyze_collection", parameters: parameters)
|
||||
.catch { error -> AnyPublisher<CollectionAnalysis, APIError> in
|
||||
// AI機能が利用できない場合のエラー
|
||||
return Fail(error: APIError.networkError("AI分析機能を利用するにはai.gptサーバーが必要です")).eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func getEnhancedStats() -> AnyPublisher<GachaStats, APIError> {
|
||||
return mcpRequest("/card_get_gacha_stats", parameters: [:])
|
||||
.catch { [weak self] error -> AnyPublisher<GachaStats, APIError> in
|
||||
// AI機能が利用できない場合は基本統計にフォールバック
|
||||
print("AI統計が利用できません、基本統計に切り替えます: \(error)")
|
||||
return self?.getGachaStats() ?? Fail(error: error).eraseToAnyPublisher()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func isAIAvailable() -> AnyPublisher<Bool, Never> {
|
||||
guard let url = URL(string: "\(aiGptBaseURL)/health") else {
|
||||
return Just(false).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return URLSession.shared.dataTaskPublisher(for: url)
|
||||
.map { _ in true }
|
||||
.catch { _ in Just(false) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
355
ios/AiCard/AiCard/Services/AtprotoOAuthService.swift
Normal file
355
ios/AiCard/AiCard/Services/AtprotoOAuthService.swift
Normal file
@ -0,0 +1,355 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import AuthenticationServices
|
||||
|
||||
struct AtprotoSession: Codable {
|
||||
let did: String
|
||||
let handle: String
|
||||
let accessJwt: String
|
||||
let refreshJwt: String
|
||||
let email: String?
|
||||
let emailConfirmed: Bool?
|
||||
}
|
||||
|
||||
class AtprotoOAuthService: NSObject, ObservableObject {
|
||||
static let shared = AtprotoOAuthService()
|
||||
|
||||
@Published var session: AtprotoSession?
|
||||
@Published var isAuthenticated: Bool = false
|
||||
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
private let clientId: String
|
||||
private let redirectUri: String
|
||||
private let scope = "atproto transition:generic"
|
||||
|
||||
override init() {
|
||||
// Generate client metadata URL
|
||||
self.clientId = "\(Bundle.main.bundleIdentifier ?? "ai.card")/client-metadata.json"
|
||||
self.redirectUri = "aicard://oauth/callback"
|
||||
|
||||
super.init()
|
||||
loadSessionFromKeychain()
|
||||
}
|
||||
|
||||
// MARK: - OAuth Flow
|
||||
|
||||
func initiateOAuthFlow() -> AnyPublisher<AtprotoSession, Error> {
|
||||
return Future { [weak self] promise in
|
||||
guard let self = self else {
|
||||
promise(.failure(OAuthError.invalidState))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let authURL = try await self.buildAuthorizationURL()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.startWebAuthenticationSession(url: authURL) { result in
|
||||
switch result {
|
||||
case .success(let session):
|
||||
self.session = session
|
||||
self.isAuthenticated = true
|
||||
self.saveSessionToKeychain(session)
|
||||
promise(.success(session))
|
||||
|
||||
case .failure(let error):
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
promise(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private func buildAuthorizationURL() async throws -> URL {
|
||||
// Generate PKCE parameters
|
||||
let state = generateRandomString(32)
|
||||
let codeVerifier = generateRandomString(128)
|
||||
let codeChallenge = try generateCodeChallenge(from: codeVerifier)
|
||||
|
||||
// Store PKCE parameters
|
||||
UserDefaults.standard.set(state, forKey: "oauth_state")
|
||||
UserDefaults.standard.set(codeVerifier, forKey: "oauth_code_verifier")
|
||||
|
||||
// For development: use mock authorization server
|
||||
// In production, this would discover the actual atproto authorization server
|
||||
let authServer = "https://bsky.social" // Mock - should be discovered
|
||||
|
||||
var components = URLComponents(string: "\(authServer)/oauth/authorize")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
URLQueryItem(name: "redirect_uri", value: redirectUri),
|
||||
URLQueryItem(name: "scope", value: scope),
|
||||
URLQueryItem(name: "state", value: state),
|
||||
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256")
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
throw OAuthError.invalidURL
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
private func startWebAuthenticationSession(url: URL, completion: @escaping (Result<AtprotoSession, Error>) -> Void) {
|
||||
authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: "aicard") { [weak self] callbackURL, error in
|
||||
|
||||
if let error = error {
|
||||
if case ASWebAuthenticationSessionError.canceledLogin = error {
|
||||
completion(.failure(OAuthError.userCancelled))
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let callbackURL = callbackURL else {
|
||||
completion(.failure(OAuthError.invalidCallback))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let session = try await self?.handleOAuthCallback(callbackURL: callbackURL)
|
||||
if let session = session {
|
||||
completion(.success(session))
|
||||
} else {
|
||||
completion(.failure(OAuthError.invalidState))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authSession?.presentationContextProvider = self
|
||||
authSession?.prefersEphemeralWebBrowserSession = false
|
||||
authSession?.start()
|
||||
}
|
||||
|
||||
private func handleOAuthCallback(callbackURL: URL) async throws -> AtprotoSession {
|
||||
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems else {
|
||||
throw OAuthError.invalidCallback
|
||||
}
|
||||
|
||||
var code: String?
|
||||
var state: String?
|
||||
var error: String?
|
||||
|
||||
for item in queryItems {
|
||||
switch item.name {
|
||||
case "code":
|
||||
code = item.value
|
||||
case "state":
|
||||
state = item.value
|
||||
case "error":
|
||||
error = item.value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
throw OAuthError.authorizationFailed(error)
|
||||
}
|
||||
|
||||
guard let code = code, let state = state else {
|
||||
throw OAuthError.missingParameters
|
||||
}
|
||||
|
||||
// Verify state
|
||||
let storedState = UserDefaults.standard.string(forKey: "oauth_state")
|
||||
guard state == storedState else {
|
||||
throw OAuthError.invalidState
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
guard let codeVerifier = UserDefaults.standard.string(forKey: "oauth_code_verifier") else {
|
||||
throw OAuthError.missingCodeVerifier
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
let session = try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
|
||||
|
||||
// Clean up temporary data
|
||||
UserDefaults.standard.removeObject(forKey: "oauth_state")
|
||||
UserDefaults.standard.removeObject(forKey: "oauth_code_verifier")
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
private func exchangeCodeForTokens(code: String, codeVerifier: String) async throws -> AtprotoSession {
|
||||
// This is a mock implementation
|
||||
// In production, this would make a proper token exchange request
|
||||
|
||||
// For development, return a mock session
|
||||
let mockSession = AtprotoSession(
|
||||
did: "did:plc:mock123456789",
|
||||
handle: "user.bsky.social",
|
||||
accessJwt: "mock_access_token",
|
||||
refreshJwt: "mock_refresh_token",
|
||||
email: nil,
|
||||
emailConfirmed: nil
|
||||
)
|
||||
|
||||
return mockSession
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func refreshTokens() async throws -> AtprotoSession {
|
||||
guard let currentSession = session else {
|
||||
throw OAuthError.noSession
|
||||
}
|
||||
|
||||
// This would make a proper token refresh request
|
||||
// For now, return the existing session
|
||||
return currentSession
|
||||
}
|
||||
|
||||
func logout() {
|
||||
session = nil
|
||||
isAuthenticated = false
|
||||
deleteSessionFromKeychain()
|
||||
|
||||
// Cancel any ongoing auth session
|
||||
authSession?.cancel()
|
||||
authSession = nil
|
||||
}
|
||||
|
||||
// MARK: - Keychain Storage
|
||||
|
||||
private func saveSessionToKeychain(_ session: AtprotoSession) {
|
||||
guard let data = try? JSONEncoder().encode(session) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: "atproto_session",
|
||||
kSecValueData as String: data
|
||||
]
|
||||
|
||||
// Delete existing item
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
// Add new item
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadSessionFromKeychain() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: "atproto_session",
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
if status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let session = try? JSONDecoder().decode(AtprotoSession.self, from: data) {
|
||||
self.session = session
|
||||
self.isAuthenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSessionFromKeychain() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: "atproto_session"
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Utility Methods
|
||||
|
||||
private func generateRandomString(_ length: Int) -> String {
|
||||
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return String((0..<length).map { _ in chars.randomElement()! })
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(from verifier: String) throws -> String {
|
||||
guard let data = verifier.data(using: .utf8) else {
|
||||
throw OAuthError.encodingError
|
||||
}
|
||||
|
||||
let digest = SHA256.hash(data: data)
|
||||
return Data(digest).base64URLEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
extension AtprotoOAuthService: ASWebAuthenticationPresentationContextProviding {
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum OAuthError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidState
|
||||
case invalidCallback
|
||||
case missingParameters
|
||||
case missingCodeVerifier
|
||||
case authorizationFailed(String)
|
||||
case userCancelled
|
||||
case noSession
|
||||
case encodingError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "無効なURLです"
|
||||
case .invalidState:
|
||||
return "無効な状態パラメータです"
|
||||
case .invalidCallback:
|
||||
return "無効なコールバックです"
|
||||
case .missingParameters:
|
||||
return "必要なパラメータが不足しています"
|
||||
case .missingCodeVerifier:
|
||||
return "コード検証子が見つかりません"
|
||||
case .authorizationFailed(let error):
|
||||
return "認証に失敗しました: \(error)"
|
||||
case .userCancelled:
|
||||
return "ユーザーによってキャンセルされました"
|
||||
case .noSession:
|
||||
return "セッションがありません"
|
||||
case .encodingError:
|
||||
return "エンコードエラーです"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Extension for Base64URL
|
||||
|
||||
extension Data {
|
||||
func base64URLEncodedString() -> String {
|
||||
return base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SHA256 (simplified for demo)
|
||||
|
||||
import CryptoKit
|
||||
|
||||
extension SHA256 {
|
||||
static func hash(data: Data) -> SHA256.Digest {
|
||||
return SHA256.hash(data: data)
|
||||
}
|
||||
}
|
@ -7,17 +7,44 @@ class AuthManager: ObservableObject {
|
||||
@Published var currentUser: User?
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var authMode: AuthMode = .oauth
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let apiClient = APIClient.shared
|
||||
private let oauthService = AtprotoOAuthService.shared
|
||||
|
||||
enum AuthMode {
|
||||
case oauth
|
||||
case legacy
|
||||
}
|
||||
|
||||
init() {
|
||||
// Monitor OAuth service
|
||||
oauthService.$isAuthenticated
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isAuth in
|
||||
if isAuth, let session = self?.oauthService.session {
|
||||
self?.isAuthenticated = true
|
||||
self?.currentUser = User(did: session.did, handle: session.handle)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
checkAuthStatus()
|
||||
}
|
||||
|
||||
private func checkAuthStatus() {
|
||||
isLoading = true
|
||||
|
||||
// Check OAuth session first
|
||||
if oauthService.isAuthenticated, let session = oauthService.session {
|
||||
isAuthenticated = true
|
||||
currentUser = User(did: session.did, handle: session.handle)
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy auth
|
||||
apiClient.verify()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
@ -36,7 +63,28 @@ class AuthManager: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func login(identifier: String, password: String) {
|
||||
func loginWithOAuth() {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
oauthService.initiateOAuthFlow()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] completion in
|
||||
self?.isLoading = false
|
||||
if case .failure(let error) = completion {
|
||||
self?.errorMessage = error.localizedDescription
|
||||
}
|
||||
},
|
||||
receiveValue: { [weak self] session in
|
||||
self?.isAuthenticated = true
|
||||
self?.currentUser = User(did: session.did, handle: session.handle)
|
||||
}
|
||||
)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func loginWithPassword(identifier: String, password: String) {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
@ -60,6 +108,9 @@ class AuthManager: ObservableObject {
|
||||
func logout() {
|
||||
isLoading = true
|
||||
|
||||
// Logout from both services
|
||||
oauthService.logout()
|
||||
|
||||
apiClient.logout()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
|
Reference in New Issue
Block a user