patct sessions list

This commit is contained in:
cheykrym 2025-10-24 10:17:15 +03:00
parent 26534e88c1
commit cf5d2ad7fb
3 changed files with 195 additions and 22 deletions

View File

@ -85,6 +85,49 @@ final class SessionsService {
} }
} }
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)
}
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date { private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer() let container = try decoder.singleValueContainer()
let string = try container.decode(String.self) let string = try container.decode(String.self)

View File

@ -108,7 +108,7 @@
}, },
"OK" : { "OK" : {
"comment" : "Common OK\nProfile update alert button", "comment" : "Common OK\nProfile update alert button\nОбщий текст кнопки OK",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -332,6 +332,9 @@
} }
} }
}, },
"Всего сессий" : {
"comment" : "Сводка по количеству сессий"
},
"Вы" : { "Вы" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -373,7 +376,7 @@
"comment" : "Global search section" "comment" : "Global search section"
}, },
"Готово" : { "Готово" : {
"comment" : "Profile update success title", "comment" : "Profile update success title\nЗаголовок успешного уведомления",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -423,6 +426,9 @@
"Добавьте контакты, чтобы быстрее выходить на связь." : { "Добавьте контакты, чтобы быстрее выходить на связь." : {
"comment" : "Contacts empty state subtitle" "comment" : "Contacts empty state subtitle"
}, },
"Другие устройства (%d)" : {
"comment" : "Заголовок секции других устройств с количеством"
},
"Другое" : { "Другое" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -447,6 +453,9 @@
}, },
"Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : { "Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : {
},
"Завершить другие сессии" : {
"comment" : "Кнопка завершения других сессий"
}, },
"Заглушка: Push-уведомления" : { "Заглушка: Push-уведомления" : {
@ -966,6 +975,9 @@
"Не удалось выполнить поиск." : { "Не удалось выполнить поиск." : {
"comment" : "Search error fallback\nSearch service decoding error" "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" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1541,6 +1553,9 @@
} }
} }
}, },
"Первый вход: %@" : {
"comment" : "Дата первого входа в сессию"
},
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : { "Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
"comment" : "FAQ answer: reset password" "comment" : "FAQ answer: reset password"
}, },
@ -1706,8 +1721,8 @@
"Попробуйте изменить запрос поиска." : { "Попробуйте изменить запрос поиска." : {
}, },
"Последнее обновление: %@" : { "Последний вход: %@" : {
"comment" : "Дата последнего обновления сессии" "comment" : "Дата последнего входа в сессию"
}, },
"Похвала" : { "Похвала" : {
"comment" : "feedback category: praise", "comment" : "feedback category: praise",
@ -2066,8 +2081,8 @@
} }
} }
}, },
"Сессии" : { "Сессий на других устройствах: %d" : {
"comment" : "Активные сессии - заголовок секции" "comment" : "Количество сессий на других устройствах"
}, },
"Сессия истекла. Войдите снова." : { "Сессия истекла. Войдите снова." : {
"comment" : "Chat creation unauthorized\nSearch error unauthorized", "comment" : "Chat creation unauthorized\nSearch error unauthorized",
@ -2148,9 +2163,6 @@
}, },
"Согласиться с правилами" : { "Согласиться с правилами" : {
},
"Создана: %@" : {
"comment" : "Дата создания сессии"
}, },
"Сообщение" : { "Сообщение" : {
@ -2228,6 +2240,12 @@
"Текущая" : { "Текущая" : {
"comment" : "Маркер текущей сессии" "comment" : "Маркер текущей сессии"
}, },
"Текущая сессия не найдена" : {
"comment" : "Сообщение об отсутствии текущей сессии"
},
"Текущая сессия останется активной" : {
"comment" : "Подсказка под кнопкой завершения других сессий"
},
"Тема: %@" : { "Тема: %@" : {
"comment" : "feedback: success category", "comment" : "feedback: success category",
"localizations" : { "localizations" : {
@ -2433,6 +2451,9 @@
}, },
"Экспериментальная поддержка iOS 15" : { "Экспериментальная поддержка iOS 15" : {
},
"Это устройство" : {
"comment" : "Заголовок секции текущего устройства"
}, },
"Я ознакомился и принимаю правила сервиса" : { "Я ознакомился и принимаю правила сервиса" : {

View File

@ -4,8 +4,16 @@ struct ActiveSessionsView: View {
@State private var sessions: [SessionViewData] = [] @State private var sessions: [SessionViewData] = []
@State private var isLoading = false @State private var isLoading = false
@State private var loadError: String? @State private var loadError: String?
@State private var revokeInProgress = false
@State private var activeAlert: SessionsAlert?
private let sessionsService = SessionsService() private let sessionsService = SessionsService()
private var currentSession: SessionViewData? {
sessions.first { $0.isCurrent }
}
private var otherSessions: [SessionViewData] {
sessions.filter { !$0.isCurrent }
}
var body: some View { var body: some View {
List { List {
@ -16,11 +24,43 @@ struct ActiveSessionsView: View {
} else if sessions.isEmpty { } else if sessions.isEmpty {
emptyState emptyState
} else { } else {
Section(header: Text(NSLocalizedString("Сессии", comment: "Активные сессии - заголовок секции"))) { Section {
ForEach(sessions) { session in 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) sessionRow(for: session)
} }
} }
Section {
revokeOtherSessionsButton
}
}
} }
} }
.navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий")) .navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий"))
@ -29,7 +69,14 @@ struct ActiveSessionsView: View {
await loadSessions() await loadSessions()
} }
.refreshable { .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) { VStack(alignment: .leading, spacing: 6) {
Label(session.createdAtText, systemImage: "calendar") Label(session.firstLoginText, systemImage: "calendar")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Label(session.lastRefreshText, systemImage: "arrow.clockwise") Label(session.lastLoginText, systemImage: "arrow.clockwise")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -119,8 +166,8 @@ struct ActiveSessionsView: View {
} }
@MainActor @MainActor
private func loadSessions() async { private func loadSessions(force: Bool = false) async {
if isLoading { if isLoading && !force {
return return
} }
@ -139,6 +186,60 @@ struct ActiveSessionsView: View {
isLoading = false 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 { private struct SessionViewData: Identifiable, Equatable {
@ -146,6 +247,7 @@ private struct SessionViewData: Identifiable, Equatable {
let ipAddress: String? let ipAddress: String?
let userAgent: String? let userAgent: String?
let clientType: String let clientType: String
let isActive: Bool
let createdAt: Date let createdAt: Date
let lastRefreshAt: Date let lastRefreshAt: Date
let isCurrent: Bool let isCurrent: Bool
@ -155,6 +257,7 @@ private struct SessionViewData: Identifiable, Equatable {
self.ipAddress = payload.ipAddress self.ipAddress = payload.ipAddress
self.userAgent = payload.userAgent self.userAgent = payload.userAgent
self.clientType = payload.clientType self.clientType = payload.clientType
self.isActive = payload.isActive
self.createdAt = payload.createdAt self.createdAt = payload.createdAt
self.lastRefreshAt = payload.lastRefreshAt self.lastRefreshAt = payload.lastRefreshAt
self.isCurrent = payload.isCurrent 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) 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) let formatted = Self.dateFormatter.string(from: lastRefreshAt)
return String(format: NSLocalizedString("Последнее обновление: %@", comment: "Дата последнего обновления сессии"), formatted) return String(format: NSLocalizedString("Последний вход: %@", comment: "Дата последнего входа в сессию"), formatted)
} }
private static let dateFormatter: DateFormatter = { private static let dateFormatter: DateFormatter = {
@ -195,3 +298,9 @@ private struct SessionViewData: Identifiable, Equatable {
return formatter return formatter
}() }()
} }
private struct SessionsAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}