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 {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)

View File

@ -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" : "Заголовок секции текущего устройства"
},
"Я ознакомился и принимаю правила сервиса" : {

View File

@ -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
}