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