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" : {
|
"OK" : {
|
||||||
"comment" : "Common OK\nProfile update alert button\nОбщий текст кнопки OK",
|
"comment" : "Common OK\nProfile update alert button",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -198,7 +198,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Активные сессии" : {
|
"Активные сессии" : {
|
||||||
"comment" : "Заголовок экрана активных сессий",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -208,9 +207,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Активные сессии не найдены" : {
|
|
||||||
"comment" : "Пустой список активных сессий"
|
|
||||||
},
|
|
||||||
"Аудио" : {
|
"Аудио" : {
|
||||||
"comment" : "Audio message placeholder"
|
"comment" : "Audio message placeholder"
|
||||||
},
|
},
|
||||||
@ -230,9 +226,6 @@
|
|||||||
"Блокировка контакта \"%1$@\" появится позже." : {
|
"Блокировка контакта \"%1$@\" появится позже." : {
|
||||||
"comment" : "Contacts block placeholder message"
|
"comment" : "Contacts block placeholder message"
|
||||||
},
|
},
|
||||||
"Бот" : {
|
|
||||||
"comment" : "Тип сессии — бот"
|
|
||||||
},
|
|
||||||
"В чате пока нет сообщений." : {
|
"В чате пока нет сообщений." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -247,9 +240,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Веб" : {
|
|
||||||
"comment" : "Тип сессии — веб"
|
|
||||||
},
|
|
||||||
"Версия:" : {
|
"Версия:" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -298,9 +288,6 @@
|
|||||||
},
|
},
|
||||||
"Вложение" : {
|
"Вложение" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Войдите с другого устройства, чтобы увидеть его здесь." : {
|
|
||||||
"comment" : "Подсказка при отсутствии активных сессий"
|
|
||||||
},
|
},
|
||||||
"Войти" : {
|
"Войти" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -332,9 +319,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Всего сессий" : {
|
|
||||||
"comment" : "Сводка по количеству сессий"
|
|
||||||
},
|
|
||||||
"Вы" : {
|
"Вы" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -345,9 +329,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Вы выйдете со всех устройств, кроме текущего." : {
|
|
||||||
"comment" : "Описание подтверждения завершения сессий"
|
|
||||||
},
|
|
||||||
"Выберите оценку — это поможет нам понять настроение." : {
|
"Выберите оценку — это поможет нам понять настроение." : {
|
||||||
"comment" : "feedback: rating hint",
|
"comment" : "feedback: rating hint",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -379,7 +360,7 @@
|
|||||||
"comment" : "Global search section"
|
"comment" : "Global search section"
|
||||||
},
|
},
|
||||||
"Готово" : {
|
"Готово" : {
|
||||||
"comment" : "Profile update success title\nЗаголовок успешного уведомления",
|
"comment" : "Profile update success title",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -409,9 +390,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Десктоп" : {
|
|
||||||
"comment" : "Тип сессии — десктоп"
|
|
||||||
},
|
|
||||||
"Добавить друзей" : {
|
"Добавить друзей" : {
|
||||||
"comment" : "Add friends",
|
"comment" : "Add friends",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -429,9 +407,6 @@
|
|||||||
"Добавьте контакты, чтобы быстрее выходить на связь." : {
|
"Добавьте контакты, чтобы быстрее выходить на связь." : {
|
||||||
"comment" : "Contacts empty state subtitle"
|
"comment" : "Contacts empty state subtitle"
|
||||||
},
|
},
|
||||||
"Другие устройства (%d)" : {
|
|
||||||
"comment" : "Заголовок секции других устройств с количеством"
|
|
||||||
},
|
|
||||||
"Другое" : {
|
"Другое" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -456,18 +431,12 @@
|
|||||||
},
|
},
|
||||||
"Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : {
|
"Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Завершить" : {
|
|
||||||
"comment" : "Подтверждение завершения других сессий"
|
|
||||||
},
|
|
||||||
"Завершить другие сессии" : {
|
|
||||||
"comment" : "Кнопка завершения других сессий"
|
|
||||||
},
|
|
||||||
"Завершить сессии на других устройствах?" : {
|
|
||||||
"comment" : "Заголовок подтверждения завершения сессий"
|
|
||||||
},
|
},
|
||||||
"Заглушка: Push-уведомления" : {
|
"Заглушка: Push-уведомления" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Заглушка: Активные сессии" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Заглушка: Двухфакторная аутентификация" : {
|
"Заглушка: Двухфакторная аутентификация" : {
|
||||||
|
|
||||||
@ -822,9 +791,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Мобильное приложение" : {
|
|
||||||
"comment" : "Тип сессии — мобильное приложение"
|
|
||||||
},
|
|
||||||
"Мои загрузки" : {
|
"Мои загрузки" : {
|
||||||
"comment" : "My Downloads",
|
"comment" : "My Downloads",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -984,12 +950,6 @@
|
|||||||
"Не удалось выполнить поиск." : {
|
"Не удалось выполнить поиск." : {
|
||||||
"comment" : "Search error fallback\nSearch service decoding error"
|
"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" : "Profile service decoding error\nProfile unexpected status"
|
||||||
},
|
},
|
||||||
"Не удалось загрузить список сессий." : {
|
|
||||||
"comment" : "Sessions service decoding error\nSessions service unexpected status"
|
|
||||||
},
|
|
||||||
"Не удалось загрузить список чатов." : {
|
"Не удалось загрузить список чатов." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"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" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1565,9 +1522,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Первый вход: %@" : {
|
|
||||||
"comment" : "Дата первого входа в сессию"
|
|
||||||
},
|
|
||||||
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
|
||||||
"comment" : "FAQ answer: reset password"
|
"comment" : "FAQ answer: reset password"
|
||||||
},
|
},
|
||||||
@ -1732,9 +1686,6 @@
|
|||||||
},
|
},
|
||||||
"Попробуйте изменить запрос поиска." : {
|
"Попробуйте изменить запрос поиска." : {
|
||||||
|
|
||||||
},
|
|
||||||
"Последний вход: %@" : {
|
|
||||||
"comment" : "Дата последнего входа в сессию"
|
|
||||||
},
|
},
|
||||||
"Похвала" : {
|
"Похвала" : {
|
||||||
"comment" : "feedback category: praise",
|
"comment" : "feedback category: praise",
|
||||||
@ -2093,9 +2044,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Сессий на других устройствах: %d" : {
|
|
||||||
"comment" : "Количество сессий на других устройствах"
|
|
||||||
},
|
|
||||||
"Сессия истекла. Войдите снова." : {
|
"Сессия истекла. Войдите снова." : {
|
||||||
"comment" : "Chat creation unauthorized\nSearch error unauthorized",
|
"comment" : "Chat creation unauthorized\nSearch error unauthorized",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2248,15 +2196,6 @@
|
|||||||
},
|
},
|
||||||
"Стикеры" : {
|
"Стикеры" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Текущая" : {
|
|
||||||
"comment" : "Маркер текущей сессии"
|
|
||||||
},
|
|
||||||
"Текущая сессия не найдена" : {
|
|
||||||
"comment" : "Сообщение об отсутствии текущей сессии"
|
|
||||||
},
|
|
||||||
"Текущая сессия останется активной" : {
|
|
||||||
"comment" : "Подсказка под кнопкой завершения других сессий"
|
|
||||||
},
|
},
|
||||||
"Тема: %@" : {
|
"Тема: %@" : {
|
||||||
"comment" : "feedback: success category",
|
"comment" : "feedback: success category",
|
||||||
@ -2463,9 +2402,6 @@
|
|||||||
},
|
},
|
||||||
"Экспериментальная поддержка iOS 15" : {
|
"Экспериментальная поддержка 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("Заглушка: Двухфакторная аутентификация")) {
|
NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) {
|
||||||
Label("Двухфакторная аутентификация", systemImage: "lock.shield")
|
Label("Двухфакторная аутентификация", systemImage: "lock.shield")
|
||||||
}
|
}
|
||||||
NavigationLink(destination: ActiveSessionsView()) {
|
NavigationLink(destination: Text("Заглушка: Активные сессии")) {
|
||||||
Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
|
Label("Активные сессии", systemImage: "iphone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user