diff --git a/yobble/Network/SessionsService.swift b/yobble/Network/SessionsService.swift index b6de65d..3dd01e3 100644 --- a/yobble/Network/SessionsService.swift +++ b/yobble/Network/SessionsService.swift @@ -85,6 +85,49 @@ final class SessionsService { } } + func revokeAllExceptCurrent(completion: @escaping (Result) -> 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.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) + } + } + } + private static func decodeDate(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index cf8e6b8..d536026 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -108,7 +108,7 @@ }, "OK" : { - "comment" : "Common OK\nProfile update alert button", + "comment" : "Common OK\nProfile update alert button\nОбщий текст кнопки OK", "localizations" : { "en" : { "stringUnit" : { @@ -332,6 +332,9 @@ } } }, + "Всего сессий" : { + "comment" : "Сводка по количеству сессий" + }, "Вы" : { "localizations" : { "en" : { @@ -373,7 +376,7 @@ "comment" : "Global search section" }, "Готово" : { - "comment" : "Profile update success title", + "comment" : "Profile update success title\nЗаголовок успешного уведомления", "localizations" : { "en" : { "stringUnit" : { @@ -423,6 +426,9 @@ "Добавьте контакты, чтобы быстрее выходить на связь." : { "comment" : "Contacts empty state subtitle" }, + "Другие устройства (%d)" : { + "comment" : "Заголовок секции других устройств с количеством" + }, "Другое" : { "localizations" : { "en" : { @@ -447,6 +453,9 @@ }, "Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : { + }, + "Завершить другие сессии" : { + "comment" : "Кнопка завершения других сессий" }, "Заглушка: Push-уведомления" : { @@ -966,6 +975,9 @@ "Не удалось выполнить поиск." : { "comment" : "Search error fallback\nSearch service decoding error" }, + "Не удалось завершить другие сессии." : { + "comment" : "Sessions service revoke-all unexpected status" + }, "Не удалось загрузить историю чата." : { }, @@ -1382,7 +1394,7 @@ } }, "Ошибка" : { - "comment" : "Common error title\nContacts load error title\nProfile update error title", + "comment" : "Common error title\nContacts load error title\nProfile update error title\nЗаголовок сообщения об ошибке", "localizations" : { "en" : { "stringUnit" : { @@ -1541,6 +1553,9 @@ } } }, + "Первый вход: %@" : { + "comment" : "Дата первого входа в сессию" + }, "Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : { "comment" : "FAQ answer: reset password" }, @@ -1706,8 +1721,8 @@ "Попробуйте изменить запрос поиска." : { }, - "Последнее обновление: %@" : { - "comment" : "Дата последнего обновления сессии" + "Последний вход: %@" : { + "comment" : "Дата последнего входа в сессию" }, "Похвала" : { "comment" : "feedback category: praise", @@ -2066,8 +2081,8 @@ } } }, - "Сессии" : { - "comment" : "Активные сессии - заголовок секции" + "Сессий на других устройствах: %d" : { + "comment" : "Количество сессий на других устройствах" }, "Сессия истекла. Войдите снова." : { "comment" : "Chat creation unauthorized\nSearch error unauthorized", @@ -2148,9 +2163,6 @@ }, "Согласиться с правилами" : { - }, - "Создана: %@" : { - "comment" : "Дата создания сессии" }, "Сообщение" : { @@ -2228,6 +2240,12 @@ "Текущая" : { "comment" : "Маркер текущей сессии" }, + "Текущая сессия не найдена" : { + "comment" : "Сообщение об отсутствии текущей сессии" + }, + "Текущая сессия останется активной" : { + "comment" : "Подсказка под кнопкой завершения других сессий" + }, "Тема: %@" : { "comment" : "feedback: success category", "localizations" : { @@ -2433,6 +2451,9 @@ }, "Экспериментальная поддержка iOS 15" : { + }, + "Это устройство" : { + "comment" : "Заголовок секции текущего устройства" }, "Я ознакомился и принимаю правила сервиса" : { diff --git a/yobble/Views/Tab/Settings/ActiveSessionsView.swift b/yobble/Views/Tab/Settings/ActiveSessionsView.swift index cf72945..a4ebac2 100644 --- a/yobble/Views/Tab/Settings/ActiveSessionsView.swift +++ b/yobble/Views/Tab/Settings/ActiveSessionsView.swift @@ -4,8 +4,16 @@ 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? 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 { @@ -16,9 +24,41 @@ struct ActiveSessionsView: View { } else if sessions.isEmpty { emptyState } else { - Section(header: Text(NSLocalizedString("Сессии", comment: "Активные сессии - заголовок секции"))) { - ForEach(sessions) { session in - sessionRow(for: session) + 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(header: Text(String(format: NSLocalizedString("Другие устройства (%d)", comment: "Заголовок секции других устройств с количеством"), otherSessions.count))) { + ForEach(otherSessions) { session in + sessionRow(for: session) + } + } + + Section { + revokeOtherSessionsButton } } } @@ -29,7 +69,14 @@ struct ActiveSessionsView: View { await loadSessions() } .refreshable { - await loadSessions() + 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"))) + ) } } @@ -107,10 +154,10 @@ struct ActiveSessionsView: View { } VStack(alignment: .leading, spacing: 6) { - Label(session.createdAtText, systemImage: "calendar") + Label(session.firstLoginText, systemImage: "calendar") .font(.caption) .foregroundColor(.secondary) - Label(session.lastRefreshText, systemImage: "arrow.clockwise") + Label(session.lastLoginText, systemImage: "arrow.clockwise") .font(.caption) .foregroundColor(.secondary) } @@ -119,8 +166,8 @@ struct ActiveSessionsView: View { } @MainActor - private func loadSessions() async { - if isLoading { + private func loadSessions(force: Bool = false) async { + if isLoading && !force { return } @@ -139,6 +186,60 @@ struct ActiveSessionsView: View { 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 { + Task { + await revokeOtherSessions() + } + } 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 { @@ -146,6 +247,7 @@ private struct SessionViewData: Identifiable, Equatable { let ipAddress: String? let userAgent: String? let clientType: String + let isActive: Bool let createdAt: Date let lastRefreshAt: Date let isCurrent: Bool @@ -155,6 +257,7 @@ private struct SessionViewData: Identifiable, Equatable { 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 @@ -176,14 +279,14 @@ private struct SessionViewData: Identifiable, Equatable { } } - var createdAtText: String { + var firstLoginText: String { let formatted = Self.dateFormatter.string(from: createdAt) - return String(format: NSLocalizedString("Создана: %@", comment: "Дата создания сессии"), formatted) + return String(format: NSLocalizedString("Первый вход: %@", comment: "Дата первого входа в сессию"), formatted) } - var lastRefreshText: String { + var lastLoginText: String { let formatted = Self.dateFormatter.string(from: lastRefreshAt) - return String(format: NSLocalizedString("Последнее обновление: %@", comment: "Дата последнего обновления сессии"), formatted) + return String(format: NSLocalizedString("Последний вход: %@", comment: "Дата последнего входа в сессию"), formatted) } private static let dateFormatter: DateFormatter = { @@ -195,3 +298,9 @@ private struct SessionViewData: Identifiable, Equatable { return formatter }() } + +private struct SessionsAlert: Identifiable { + let id = UUID() + let title: String + let message: String +}