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,9 +24,41 @@ 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 {
 | 
				
			||||||
                        sessionRow(for: session)
 | 
					                        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()
 | 
					            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