Compare commits
	
		
			No commits in common. "020aa8de5d2490b14888ce18d40a37fdc2d38ea7" and "7034503983d458cca090ea078b4c408e8fa22021" have entirely different histories.
		
	
	
		
			020aa8de5d
			...
			7034503983
		
	
		
@ -1,265 +0,0 @@
 | 
			
		||||
import Foundation
 | 
			
		||||
 | 
			
		||||
enum SessionsServiceError: LocalizedError {
 | 
			
		||||
    case unexpectedStatus(String)
 | 
			
		||||
    case decoding(debugDescription: String)
 | 
			
		||||
 | 
			
		||||
    var errorDescription: String? {
 | 
			
		||||
        switch self {
 | 
			
		||||
        case .unexpectedStatus(let message):
 | 
			
		||||
            return message
 | 
			
		||||
        case .decoding(let debugDescription):
 | 
			
		||||
            return AppConfig.DEBUG
 | 
			
		||||
                ? debugDescription
 | 
			
		||||
                : NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service decoding error")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct UserSessionPayload: Decodable {
 | 
			
		||||
    let id: UUID
 | 
			
		||||
    let ipAddress: String?
 | 
			
		||||
    let userAgent: String?
 | 
			
		||||
    let clientType: String
 | 
			
		||||
    let isActive: Bool
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let lastRefreshAt: Date
 | 
			
		||||
    let isCurrent: Bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private struct SessionsListPayload: Decodable {
 | 
			
		||||
    let sessions: [UserSessionPayload]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final class SessionsService {
 | 
			
		||||
    private let client: NetworkClient
 | 
			
		||||
    private let decoder: JSONDecoder
 | 
			
		||||
 | 
			
		||||
    init(client: NetworkClient = .shared) {
 | 
			
		||||
        self.client = client
 | 
			
		||||
        self.decoder = JSONDecoder()
 | 
			
		||||
        self.decoder.keyDecodingStrategy = .convertFromSnakeCase
 | 
			
		||||
        self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func fetchSessions(completion: @escaping (Result<[UserSessionPayload], Error>) -> Void) {
 | 
			
		||||
        client.request(
 | 
			
		||||
            path: "/v1/auth/sessions/list",
 | 
			
		||||
            method: .get,
 | 
			
		||||
            requiresAuth: true
 | 
			
		||||
        ) { [decoder] result in
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .success(let response):
 | 
			
		||||
                do {
 | 
			
		||||
                    let apiResponse = try decoder.decode(APIResponse<SessionsListPayload>.self, from: response.data)
 | 
			
		||||
                    guard apiResponse.status == "fine" else {
 | 
			
		||||
                        let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service unexpected status")
 | 
			
		||||
                        completion(.failure(SessionsServiceError.unexpectedStatus(message)))
 | 
			
		||||
                        return
 | 
			
		||||
                    }
 | 
			
		||||
                    completion(.success(apiResponse.data.sessions))
 | 
			
		||||
                } catch {
 | 
			
		||||
                    let debugMessage = Self.describeDecodingError(error: error, data: response.data)
 | 
			
		||||
                    if AppConfig.DEBUG {
 | 
			
		||||
                        print("[SessionsService] decode sessions failed: \(debugMessage)")
 | 
			
		||||
                    }
 | 
			
		||||
                    completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
 | 
			
		||||
                }
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                if case let NetworkError.server(_, data) = error,
 | 
			
		||||
                   let data,
 | 
			
		||||
                   let message = Self.errorMessage(from: data) {
 | 
			
		||||
                    completion(.failure(SessionsServiceError.unexpectedStatus(message)))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                completion(.failure(error))
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func fetchSessions() async throws -> [UserSessionPayload] {
 | 
			
		||||
        try await withCheckedThrowingContinuation { continuation in
 | 
			
		||||
            fetchSessions { result in
 | 
			
		||||
                continuation.resume(with: result)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func revokeAllExceptCurrent(completion: @escaping (Result<String, Error>) -> Void) {
 | 
			
		||||
        client.request(
 | 
			
		||||
            path: "/v1/auth/sessions/revoke_all_except_current",
 | 
			
		||||
            method: .post,
 | 
			
		||||
            requiresAuth: true
 | 
			
		||||
        ) { [decoder] result in
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .success(let response):
 | 
			
		||||
                do {
 | 
			
		||||
                    let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
 | 
			
		||||
                    guard apiResponse.status == "fine" else {
 | 
			
		||||
                        let message = apiResponse.detail ?? NSLocalizedString("Не удалось завершить другие сессии.", comment: "Sessions service revoke-all unexpected status")
 | 
			
		||||
                        completion(.failure(SessionsServiceError.unexpectedStatus(message)))
 | 
			
		||||
                        return
 | 
			
		||||
                    }
 | 
			
		||||
                    completion(.success(apiResponse.data.message))
 | 
			
		||||
                } catch {
 | 
			
		||||
                    let debugMessage = Self.describeDecodingError(error: error, data: response.data)
 | 
			
		||||
                    if AppConfig.DEBUG {
 | 
			
		||||
                        print("[SessionsService] decode revoke-all failed: \(debugMessage)")
 | 
			
		||||
                    }
 | 
			
		||||
                    completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
 | 
			
		||||
                }
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                if case let NetworkError.server(_, data) = error,
 | 
			
		||||
                   let data,
 | 
			
		||||
                   let message = Self.errorMessage(from: data) {
 | 
			
		||||
                    completion(.failure(SessionsServiceError.unexpectedStatus(message)))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                completion(.failure(error))
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func revokeAllExceptCurrent() async throws -> String {
 | 
			
		||||
        try await withCheckedThrowingContinuation { continuation in
 | 
			
		||||
            revokeAllExceptCurrent { result in
 | 
			
		||||
                continuation.resume(with: result)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func revoke(sessionId: UUID, completion: @escaping (Result<String, Error>) -> Void) {
 | 
			
		||||
        client.request(
 | 
			
		||||
            path: "/v1/auth/sessions/revoke/\(sessionId.uuidString)",
 | 
			
		||||
            method: .post,
 | 
			
		||||
            requiresAuth: true
 | 
			
		||||
        ) { [decoder] result in
 | 
			
		||||
            switch result {
 | 
			
		||||
            case .success(let response):
 | 
			
		||||
                do {
 | 
			
		||||
                    let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
 | 
			
		||||
                    guard apiResponse.status == "fine" else {
 | 
			
		||||
                        let message = apiResponse.detail ?? NSLocalizedString("Не удалось завершить сессию.", comment: "Sessions service revoke unexpected status")
 | 
			
		||||
                        completion(.failure(SessionsServiceError.unexpectedStatus(message)))
 | 
			
		||||
                        return
 | 
			
		||||
                    }
 | 
			
		||||
                    completion(.success(apiResponse.data.message))
 | 
			
		||||
                } catch {
 | 
			
		||||
                    let debugMessage = Self.describeDecodingError(error: error, data: response.data)
 | 
			
		||||
                    if AppConfig.DEBUG {
 | 
			
		||||
                        print("[SessionsService] decode revoke failed: \(debugMessage)")
 | 
			
		||||
                    }
 | 
			
		||||
                    completion(.failure(SessionsServiceError.decoding(debugDescription: debugMessage)))
 | 
			
		||||
                }
 | 
			
		||||
            case .failure(let error):
 | 
			
		||||
                if case let NetworkError.server(_, data) = error,
 | 
			
		||||
                   let data,
 | 
			
		||||
                   let message = Self.errorMessage(from: data) {
 | 
			
		||||
                    completion(.failure(SessionsServiceError.unexpectedStatus(message)))
 | 
			
		||||
                    return
 | 
			
		||||
                }
 | 
			
		||||
                completion(.failure(error))
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    func revoke(sessionId: UUID) async throws -> String {
 | 
			
		||||
        try await withCheckedThrowingContinuation { continuation in
 | 
			
		||||
            revoke(sessionId: sessionId) { result in
 | 
			
		||||
                continuation.resume(with: result)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func decodeDate(from decoder: Decoder) throws -> Date {
 | 
			
		||||
        let container = try decoder.singleValueContainer()
 | 
			
		||||
        let string = try container.decode(String.self)
 | 
			
		||||
        if let date = iso8601WithFractionalSeconds.date(from: string) {
 | 
			
		||||
            return date
 | 
			
		||||
        }
 | 
			
		||||
        if let date = iso8601Simple.date(from: string) {
 | 
			
		||||
            return date
 | 
			
		||||
        }
 | 
			
		||||
        throw DecodingError.dataCorruptedError(
 | 
			
		||||
            in: container,
 | 
			
		||||
            debugDescription: "Невозможно декодировать дату: \(string)"
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func describeDecodingError(error: Error, data: Data) -> String {
 | 
			
		||||
        var parts: [String] = []
 | 
			
		||||
 | 
			
		||||
        if let decodingError = error as? DecodingError {
 | 
			
		||||
            parts.append(decodingDescription(from: decodingError))
 | 
			
		||||
        } else {
 | 
			
		||||
            parts.append(error.localizedDescription)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if let payload = truncatedPayload(from: data) {
 | 
			
		||||
            parts.append("payload=\(payload)")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return parts.joined(separator: "\n")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func decodingDescription(from error: DecodingError) -> String {
 | 
			
		||||
        switch error {
 | 
			
		||||
        case .typeMismatch(let type, let context):
 | 
			
		||||
            return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
 | 
			
		||||
        case .valueNotFound(let type, let context):
 | 
			
		||||
            return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
 | 
			
		||||
        case .keyNotFound(let key, let context):
 | 
			
		||||
            return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
 | 
			
		||||
        case .dataCorrupted(let context):
 | 
			
		||||
            return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
 | 
			
		||||
        @unknown default:
 | 
			
		||||
            return error.localizedDescription
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func codingPath(from context: DecodingError.Context) -> String {
 | 
			
		||||
        let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
 | 
			
		||||
        return path.isEmpty ? "root" : path.joined(separator: ".")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
 | 
			
		||||
        guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
 | 
			
		||||
              !string.isEmpty else {
 | 
			
		||||
            return nil
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if string.count <= limit {
 | 
			
		||||
            return string
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let index = string.index(string.startIndex, offsetBy: limit)
 | 
			
		||||
        return String(string[string.startIndex..<index]) + "…"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static func errorMessage(from data: Data) -> String? {
 | 
			
		||||
        if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
 | 
			
		||||
            if let detail = apiError.detail, !detail.isEmpty {
 | 
			
		||||
                return detail
 | 
			
		||||
            }
 | 
			
		||||
            if let message = apiError.data?.message, !message.isEmpty {
 | 
			
		||||
                return message
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if let string = String(data: data, encoding: .utf8), !string.isEmpty {
 | 
			
		||||
            return string
 | 
			
		||||
        }
 | 
			
		||||
        return nil
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
 | 
			
		||||
        let formatter = ISO8601DateFormatter()
 | 
			
		||||
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
 | 
			
		||||
        return formatter
 | 
			
		||||
    }()
 | 
			
		||||
 | 
			
		||||
    private static let iso8601Simple: ISO8601DateFormatter = {
 | 
			
		||||
        let formatter = ISO8601DateFormatter()
 | 
			
		||||
        formatter.formatOptions = [.withInternetDateTime]
 | 
			
		||||
        return formatter
 | 
			
		||||
    }()
 | 
			
		||||
}
 | 
			
		||||
@ -108,7 +108,7 @@
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "OK" : {
 | 
			
		||||
      "comment" : "Common OK\nProfile update alert button\nОбщий текст кнопки OK",
 | 
			
		||||
      "comment" : "Common OK\nProfile update alert button",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
          "stringUnit" : {
 | 
			
		||||
@ -198,7 +198,6 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Активные сессии" : {
 | 
			
		||||
      "comment" : "Заголовок экрана активных сессий",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
          "stringUnit" : {
 | 
			
		||||
@ -208,9 +207,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Активные сессии не найдены" : {
 | 
			
		||||
      "comment" : "Пустой список активных сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Аудио" : {
 | 
			
		||||
      "comment" : "Audio message placeholder"
 | 
			
		||||
    },
 | 
			
		||||
@ -230,9 +226,6 @@
 | 
			
		||||
    "Блокировка контакта \"%1$@\" появится позже." : {
 | 
			
		||||
      "comment" : "Contacts block placeholder message"
 | 
			
		||||
    },
 | 
			
		||||
    "Бот" : {
 | 
			
		||||
      "comment" : "Тип сессии — бот"
 | 
			
		||||
    },
 | 
			
		||||
    "В чате пока нет сообщений." : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
@ -247,9 +240,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Веб" : {
 | 
			
		||||
      "comment" : "Тип сессии — веб"
 | 
			
		||||
    },
 | 
			
		||||
    "Версия:" : {
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
@ -298,9 +288,6 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Вложение" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Войдите с другого устройства, чтобы увидеть его здесь." : {
 | 
			
		||||
      "comment" : "Подсказка при отсутствии активных сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Войти" : {
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
@ -332,9 +319,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Всего сессий" : {
 | 
			
		||||
      "comment" : "Сводка по количеству сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Вы" : {
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
@ -345,9 +329,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Вы выйдете со всех устройств, кроме текущего." : {
 | 
			
		||||
      "comment" : "Описание подтверждения завершения сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Выберите оценку — это поможет нам понять настроение." : {
 | 
			
		||||
      "comment" : "feedback: rating hint",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
@ -379,7 +360,7 @@
 | 
			
		||||
      "comment" : "Global search section"
 | 
			
		||||
    },
 | 
			
		||||
    "Готово" : {
 | 
			
		||||
      "comment" : "Profile update success title\nЗаголовок успешного уведомления",
 | 
			
		||||
      "comment" : "Profile update success title",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
          "stringUnit" : {
 | 
			
		||||
@ -409,9 +390,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Десктоп" : {
 | 
			
		||||
      "comment" : "Тип сессии — десктоп"
 | 
			
		||||
    },
 | 
			
		||||
    "Добавить друзей" : {
 | 
			
		||||
      "comment" : "Add friends",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
@ -429,9 +407,6 @@
 | 
			
		||||
    "Добавьте контакты, чтобы быстрее выходить на связь." : {
 | 
			
		||||
      "comment" : "Contacts empty state subtitle"
 | 
			
		||||
    },
 | 
			
		||||
    "Другие устройства (%d)" : {
 | 
			
		||||
      "comment" : "Заголовок секции других устройств с количеством"
 | 
			
		||||
    },
 | 
			
		||||
    "Другое" : {
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
@ -456,18 +431,12 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Завершить" : {
 | 
			
		||||
      "comment" : "Подтверждение завершения других сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Завершить другие сессии" : {
 | 
			
		||||
      "comment" : "Кнопка завершения других сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Завершить сессии на других устройствах?" : {
 | 
			
		||||
      "comment" : "Заголовок подтверждения завершения сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Заглушка: Push-уведомления" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Заглушка: Активные сессии" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Заглушка: Двухфакторная аутентификация" : {
 | 
			
		||||
 | 
			
		||||
@ -822,9 +791,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Мобильное приложение" : {
 | 
			
		||||
      "comment" : "Тип сессии — мобильное приложение"
 | 
			
		||||
    },
 | 
			
		||||
    "Мои загрузки" : {
 | 
			
		||||
      "comment" : "My Downloads",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
@ -984,12 +950,6 @@
 | 
			
		||||
    "Не удалось выполнить поиск." : {
 | 
			
		||||
      "comment" : "Search error fallback\nSearch service decoding error"
 | 
			
		||||
    },
 | 
			
		||||
    "Не удалось завершить другие сессии." : {
 | 
			
		||||
      "comment" : "Sessions service revoke-all unexpected status"
 | 
			
		||||
    },
 | 
			
		||||
    "Не удалось завершить сессию." : {
 | 
			
		||||
      "comment" : "Sessions service revoke unexpected status"
 | 
			
		||||
    },
 | 
			
		||||
    "Не удалось загрузить историю чата." : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
@ -999,9 +959,6 @@
 | 
			
		||||
    "Не удалось загрузить профиль." : {
 | 
			
		||||
      "comment" : "Profile service decoding error\nProfile unexpected status"
 | 
			
		||||
    },
 | 
			
		||||
    "Не удалось загрузить список сессий." : {
 | 
			
		||||
      "comment" : "Sessions service decoding error\nSessions service unexpected status"
 | 
			
		||||
    },
 | 
			
		||||
    "Не удалось загрузить список чатов." : {
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
@ -1356,7 +1313,7 @@
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Отмена" : {
 | 
			
		||||
      "comment" : "Common cancel\nОбщий текст кнопки отмены"
 | 
			
		||||
      "comment" : "Common cancel"
 | 
			
		||||
    },
 | 
			
		||||
    "Отображаемое имя" : {
 | 
			
		||||
 | 
			
		||||
@ -1406,7 +1363,7 @@
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Ошибка" : {
 | 
			
		||||
      "comment" : "Common error title\nContacts load error title\nProfile update error title\nЗаголовок сообщения об ошибке",
 | 
			
		||||
      "comment" : "Common error title\nContacts load error title\nProfile update error title",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
        "en" : {
 | 
			
		||||
          "stringUnit" : {
 | 
			
		||||
@ -1565,9 +1522,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Первый вход: %@" : {
 | 
			
		||||
      "comment" : "Дата первого входа в сессию"
 | 
			
		||||
    },
 | 
			
		||||
    "Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
 | 
			
		||||
      "comment" : "FAQ answer: reset password"
 | 
			
		||||
    },
 | 
			
		||||
@ -1732,9 +1686,6 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Попробуйте изменить запрос поиска." : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Последний вход: %@" : {
 | 
			
		||||
      "comment" : "Дата последнего входа в сессию"
 | 
			
		||||
    },
 | 
			
		||||
    "Похвала" : {
 | 
			
		||||
      "comment" : "feedback category: praise",
 | 
			
		||||
@ -2093,9 +2044,6 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "Сессий на других устройствах: %d" : {
 | 
			
		||||
      "comment" : "Количество сессий на других устройствах"
 | 
			
		||||
    },
 | 
			
		||||
    "Сессия истекла. Войдите снова." : {
 | 
			
		||||
      "comment" : "Chat creation unauthorized\nSearch error unauthorized",
 | 
			
		||||
      "localizations" : {
 | 
			
		||||
@ -2248,15 +2196,6 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Стикеры" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Текущая" : {
 | 
			
		||||
      "comment" : "Маркер текущей сессии"
 | 
			
		||||
    },
 | 
			
		||||
    "Текущая сессия не найдена" : {
 | 
			
		||||
      "comment" : "Сообщение об отсутствии текущей сессии"
 | 
			
		||||
    },
 | 
			
		||||
    "Текущая сессия останется активной" : {
 | 
			
		||||
      "comment" : "Подсказка под кнопкой завершения других сессий"
 | 
			
		||||
    },
 | 
			
		||||
    "Тема: %@" : {
 | 
			
		||||
      "comment" : "feedback: success category",
 | 
			
		||||
@ -2463,9 +2402,6 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Экспериментальная поддержка iOS 15" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Это устройство" : {
 | 
			
		||||
      "comment" : "Заголовок секции текущего устройства"
 | 
			
		||||
    },
 | 
			
		||||
    "Я ознакомился и принимаю правила сервиса" : {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,320 +0,0 @@
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ActiveSessionsView: View {
 | 
			
		||||
    @State private var sessions: [SessionViewData] = []
 | 
			
		||||
    @State private var isLoading = false
 | 
			
		||||
    @State private var loadError: String?
 | 
			
		||||
    @State private var revokeInProgress = false
 | 
			
		||||
    @State private var activeAlert: SessionsAlert?
 | 
			
		||||
    @State private var showRevokeConfirmation = false
 | 
			
		||||
 | 
			
		||||
    private let sessionsService = SessionsService()
 | 
			
		||||
    private var currentSession: SessionViewData? {
 | 
			
		||||
        sessions.first { $0.isCurrent }
 | 
			
		||||
    }
 | 
			
		||||
    private var otherSessions: [SessionViewData] {
 | 
			
		||||
        sessions.filter { !$0.isCurrent }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        List {
 | 
			
		||||
            if isLoading && sessions.isEmpty {
 | 
			
		||||
                loadingState
 | 
			
		||||
            } else if let loadError, sessions.isEmpty {
 | 
			
		||||
                errorState(loadError)
 | 
			
		||||
            } else if sessions.isEmpty {
 | 
			
		||||
                emptyState
 | 
			
		||||
            } else {
 | 
			
		||||
                Section {
 | 
			
		||||
                    HStack {
 | 
			
		||||
                        Text(NSLocalizedString("Всего сессий", comment: "Сводка по количеству сессий"))
 | 
			
		||||
                            .font(.subheadline)
 | 
			
		||||
                        Spacer()
 | 
			
		||||
                        Text("\(sessions.count)")
 | 
			
		||||
                            .font(.subheadline.weight(.semibold))
 | 
			
		||||
                    }
 | 
			
		||||
                    if !otherSessions.isEmpty {
 | 
			
		||||
                        Text(String(format: NSLocalizedString("Сессий на других устройствах: %d", comment: "Количество сессий на других устройствах"), otherSessions.count))
 | 
			
		||||
                            .font(.footnote)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                Section(header: Text(NSLocalizedString("Это устройство", comment: "Заголовок секции текущего устройства"))) {
 | 
			
		||||
                    if let currentSession {
 | 
			
		||||
                        sessionRow(for: currentSession)
 | 
			
		||||
                    } else {
 | 
			
		||||
                        Text(NSLocalizedString("Текущая сессия не найдена", comment: "Сообщение об отсутствии текущей сессии"))
 | 
			
		||||
                            .font(.footnote)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                            .padding(.vertical, 8)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if !otherSessions.isEmpty{
 | 
			
		||||
                    Section {
 | 
			
		||||
                        revokeOtherSessionsButton
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if !otherSessions.isEmpty {
 | 
			
		||||
                    Section(header: Text(String(format: NSLocalizedString("Другие устройства (%d)", comment: "Заголовок секции других устройств с количеством"), otherSessions.count))) {
 | 
			
		||||
                        ForEach(otherSessions) { session in
 | 
			
		||||
                            sessionRow(for: session)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий"))
 | 
			
		||||
        .navigationBarTitleDisplayMode(.inline)
 | 
			
		||||
        .task {
 | 
			
		||||
            await loadSessions()
 | 
			
		||||
        }
 | 
			
		||||
        .refreshable {
 | 
			
		||||
            await loadSessions(force: true)
 | 
			
		||||
        }
 | 
			
		||||
        .alert(item: $activeAlert) { alert in
 | 
			
		||||
            Alert(
 | 
			
		||||
                title: Text(alert.title),
 | 
			
		||||
                message: Text(alert.message),
 | 
			
		||||
                dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK")))
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
        .confirmationDialog(
 | 
			
		||||
            NSLocalizedString("Завершить сессии на других устройствах?", comment: "Заголовок подтверждения завершения сессий"),
 | 
			
		||||
            isPresented: $showRevokeConfirmation
 | 
			
		||||
        ) {
 | 
			
		||||
            Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения других сессий"), role: .destructive) {
 | 
			
		||||
                Task { await revokeOtherSessions() }
 | 
			
		||||
            }
 | 
			
		||||
            Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {}
 | 
			
		||||
        } message: {
 | 
			
		||||
            Text(NSLocalizedString("Вы выйдете со всех устройств, кроме текущего.", comment: "Описание подтверждения завершения сессий"))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var loadingState: some View {
 | 
			
		||||
        Section {
 | 
			
		||||
            ProgressView()
 | 
			
		||||
                .frame(maxWidth: .infinity, alignment: .center)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func errorState(_ message: String) -> some View {
 | 
			
		||||
        Section {
 | 
			
		||||
            VStack(spacing: 12) {
 | 
			
		||||
                Image(systemName: "exclamationmark.triangle")
 | 
			
		||||
                    .font(.system(size: 40))
 | 
			
		||||
                    .foregroundColor(.orange)
 | 
			
		||||
                Text(message)
 | 
			
		||||
                    .font(.body)
 | 
			
		||||
                    .multilineTextAlignment(.center)
 | 
			
		||||
            }
 | 
			
		||||
            .frame(maxWidth: .infinity)
 | 
			
		||||
            .padding(.vertical, 24)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var emptyState: some View {
 | 
			
		||||
        Section {
 | 
			
		||||
            VStack(spacing: 12) {
 | 
			
		||||
                Image(systemName: "iphone")
 | 
			
		||||
                    .font(.system(size: 40))
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                Text(NSLocalizedString("Активные сессии не найдены", comment: "Пустой список активных сессий"))
 | 
			
		||||
                    .font(.headline)
 | 
			
		||||
                    .multilineTextAlignment(.center)
 | 
			
		||||
                Text(NSLocalizedString("Войдите с другого устройства, чтобы увидеть его здесь.", comment: "Подсказка при отсутствии активных сессий"))
 | 
			
		||||
                    .font(.subheadline)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                    .multilineTextAlignment(.center)
 | 
			
		||||
            }
 | 
			
		||||
            .frame(maxWidth: .infinity)
 | 
			
		||||
            .padding(.vertical, 24)
 | 
			
		||||
        }
 | 
			
		||||
        .listRowSeparator(.hidden)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private func sessionRow(for session: SessionViewData) -> some View {
 | 
			
		||||
        VStack(alignment: .leading, spacing: 10) {
 | 
			
		||||
            HStack(alignment: .top) {
 | 
			
		||||
                VStack(alignment: .leading, spacing: 6) {
 | 
			
		||||
                    Text(session.clientTypeDisplay)
 | 
			
		||||
                        .font(.headline)
 | 
			
		||||
                    if let ip = session.ipAddress, !ip.isEmpty {
 | 
			
		||||
                        Label(ip, systemImage: "globe")
 | 
			
		||||
                            .font(.subheadline)
 | 
			
		||||
                            .foregroundColor(.secondary)
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                Spacer()
 | 
			
		||||
                if session.isCurrent {
 | 
			
		||||
                    Text(NSLocalizedString("Текущая", comment: "Маркер текущей сессии"))
 | 
			
		||||
                        .font(.caption2.weight(.semibold))
 | 
			
		||||
                        .padding(.horizontal, 10)
 | 
			
		||||
                        .padding(.vertical, 4)
 | 
			
		||||
                        .background(Color.accentColor.opacity(0.15))
 | 
			
		||||
                        .foregroundColor(.accentColor)
 | 
			
		||||
                        .clipShape(Capsule())
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if let userAgent = session.userAgent, !userAgent.isEmpty {
 | 
			
		||||
                Text(userAgent)
 | 
			
		||||
                    .font(.footnote)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                    .lineLimit(3)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            VStack(alignment: .leading, spacing: 6) {
 | 
			
		||||
                Label(session.firstLoginText, systemImage: "calendar")
 | 
			
		||||
                    .font(.caption)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
                Label(session.lastLoginText, systemImage: "arrow.clockwise")
 | 
			
		||||
                    .font(.caption)
 | 
			
		||||
                    .foregroundColor(.secondary)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .padding(.vertical, 6)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @MainActor
 | 
			
		||||
    private func loadSessions(force: Bool = false) async {
 | 
			
		||||
        if isLoading && !force {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = true
 | 
			
		||||
        loadError = nil
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let payloads = try await sessionsService.fetchSessions()
 | 
			
		||||
            sessions = payloads.map(SessionViewData.init)
 | 
			
		||||
        } catch {
 | 
			
		||||
            loadError = error.localizedDescription
 | 
			
		||||
            if AppConfig.DEBUG {
 | 
			
		||||
                print("[ActiveSessionsView] load sessions failed: \(error)")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        isLoading = false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @MainActor
 | 
			
		||||
    private func revokeOtherSessions() async {
 | 
			
		||||
        if revokeInProgress {
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        revokeInProgress = true
 | 
			
		||||
        defer { revokeInProgress = false }
 | 
			
		||||
 | 
			
		||||
        do {
 | 
			
		||||
            let message = try await sessionsService.revokeAllExceptCurrent()
 | 
			
		||||
            activeAlert = SessionsAlert(
 | 
			
		||||
                title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
 | 
			
		||||
                message: message
 | 
			
		||||
            )
 | 
			
		||||
            await loadSessions(force: true)
 | 
			
		||||
        } catch {
 | 
			
		||||
            activeAlert = SessionsAlert(
 | 
			
		||||
                title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
 | 
			
		||||
                message: error.localizedDescription
 | 
			
		||||
            )
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var revokeOtherSessionsButton: some View {
 | 
			
		||||
        let primaryColor: Color = revokeInProgress ? .secondary : .red
 | 
			
		||||
 | 
			
		||||
        return Button {
 | 
			
		||||
            if !revokeInProgress {
 | 
			
		||||
                showRevokeConfirmation = true
 | 
			
		||||
            }
 | 
			
		||||
        } label: {
 | 
			
		||||
            HStack(spacing: 12) {
 | 
			
		||||
                if revokeInProgress {
 | 
			
		||||
                    ProgressView()
 | 
			
		||||
                        .progressViewStyle(.circular)
 | 
			
		||||
                } else {
 | 
			
		||||
                    Image(systemName: "xmark.circle")
 | 
			
		||||
                        .foregroundColor(primaryColor)
 | 
			
		||||
                }
 | 
			
		||||
                VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                    Text(NSLocalizedString("Завершить другие сессии", comment: "Кнопка завершения других сессий"))
 | 
			
		||||
                        .foregroundColor(primaryColor)
 | 
			
		||||
                    Text(NSLocalizedString("Текущая сессия останется активной", comment: "Подсказка под кнопкой завершения других сессий"))
 | 
			
		||||
                        .font(.caption)
 | 
			
		||||
                        .foregroundColor(.secondary)
 | 
			
		||||
                }
 | 
			
		||||
                Spacer()
 | 
			
		||||
            }
 | 
			
		||||
            .frame(maxWidth: .infinity, alignment: .leading)
 | 
			
		||||
        }
 | 
			
		||||
        .disabled(revokeInProgress)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private struct SessionViewData: Identifiable, Equatable {
 | 
			
		||||
    let id: UUID
 | 
			
		||||
    let ipAddress: String?
 | 
			
		||||
    let userAgent: String?
 | 
			
		||||
    let clientType: String
 | 
			
		||||
    let isActive: Bool
 | 
			
		||||
    let createdAt: Date
 | 
			
		||||
    let lastRefreshAt: Date
 | 
			
		||||
    let isCurrent: Bool
 | 
			
		||||
 | 
			
		||||
    init(payload: UserSessionPayload) {
 | 
			
		||||
        self.id = payload.id
 | 
			
		||||
        self.ipAddress = payload.ipAddress
 | 
			
		||||
        self.userAgent = payload.userAgent
 | 
			
		||||
        self.clientType = payload.clientType
 | 
			
		||||
        self.isActive = payload.isActive
 | 
			
		||||
        self.createdAt = payload.createdAt
 | 
			
		||||
        self.lastRefreshAt = payload.lastRefreshAt
 | 
			
		||||
        self.isCurrent = payload.isCurrent
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var clientTypeDisplay: String {
 | 
			
		||||
        let normalized = clientType.lowercased()
 | 
			
		||||
        switch normalized {
 | 
			
		||||
        case "mobile":
 | 
			
		||||
            return NSLocalizedString("Мобильное приложение", comment: "Тип сессии — мобильное приложение")
 | 
			
		||||
        case "web":
 | 
			
		||||
            return NSLocalizedString("Веб", comment: "Тип сессии — веб")
 | 
			
		||||
        case "desktop":
 | 
			
		||||
            return NSLocalizedString("Десктоп", comment: "Тип сессии — десктоп")
 | 
			
		||||
        case "bot":
 | 
			
		||||
            return NSLocalizedString("Бот", comment: "Тип сессии — бот")
 | 
			
		||||
        default:
 | 
			
		||||
            return clientType.capitalized
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var firstLoginText: String {
 | 
			
		||||
        let formatted = Self.dateFormatter.string(from: createdAt)
 | 
			
		||||
        return String(format: NSLocalizedString("Первый вход: %@", comment: "Дата первого входа в сессию"), formatted)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var lastLoginText: String {
 | 
			
		||||
        let formatted = Self.dateFormatter.string(from: lastRefreshAt)
 | 
			
		||||
        return String(format: NSLocalizedString("Последний вход: %@", comment: "Дата последнего входа в сессию"), formatted)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static let dateFormatter: DateFormatter = {
 | 
			
		||||
        let formatter = DateFormatter()
 | 
			
		||||
        formatter.locale = Locale.current
 | 
			
		||||
        formatter.timeZone = .current
 | 
			
		||||
        formatter.dateStyle = .medium
 | 
			
		||||
        formatter.timeStyle = .short
 | 
			
		||||
        return formatter
 | 
			
		||||
    }()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
private struct SessionsAlert: Identifiable {
 | 
			
		||||
    let id = UUID()
 | 
			
		||||
    let title: String
 | 
			
		||||
    let message: String
 | 
			
		||||
}
 | 
			
		||||
@ -35,8 +35,8 @@ struct SettingsView: View {
 | 
			
		||||
                NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) {
 | 
			
		||||
                    Label("Двухфакторная аутентификация", systemImage: "lock.shield")
 | 
			
		||||
                }
 | 
			
		||||
                NavigationLink(destination: ActiveSessionsView()) {
 | 
			
		||||
                    Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
 | 
			
		||||
                NavigationLink(destination: Text("Заглушка: Активные сессии")) {
 | 
			
		||||
                    Label("Активные сессии", systemImage: "iphone")
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user