patct sessions list
This commit is contained in:
parent
26534e88c1
commit
cf5d2ad7fb
@ -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)
|
||||
|
||||
@ -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" : "Заголовок секции текущего устройства"
|
||||
},
|
||||
"Я ознакомился и принимаю правила сервиса" : {
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user