import SwiftUI 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? @State private var showRevokeConfirmation = false 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 { if isLoading && sessions.isEmpty { loadingState } else if let loadError, sessions.isEmpty { errorState(loadError) } else if sessions.isEmpty { emptyState } else { 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 { revokeOtherSessionsButton } } if !otherSessions.isEmpty { Section(header: Text(String(format: NSLocalizedString("Другие устройства (%d)", comment: "Заголовок секции других устройств с количеством"), otherSessions.count))) { ForEach(otherSessions) { session in sessionRow(for: session) } } } } } .navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий")) .navigationBarTitleDisplayMode(.inline) .task { await loadSessions() } .refreshable { 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"))) ) } .confirmationDialog( NSLocalizedString("Завершить сессии на других устройствах?", comment: "Заголовок подтверждения завершения сессий"), isPresented: $showRevokeConfirmation ) { Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения других сессий"), role: .destructive) { Task { await revokeOtherSessions() } } Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {} } message: { Text(NSLocalizedString("Вы выйдете со всех устройств, кроме текущего.", comment: "Описание подтверждения завершения сессий")) } } private var loadingState: some View { Section { ProgressView() .frame(maxWidth: .infinity, alignment: .center) } } private func errorState(_ message: String) -> some View { Section { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 40)) .foregroundColor(.orange) Text(message) .font(.body) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 24) } } private var emptyState: some View { Section { VStack(spacing: 12) { Image(systemName: "iphone") .font(.system(size: 40)) .foregroundColor(.secondary) Text(NSLocalizedString("Активные сессии не найдены", comment: "Пустой список активных сессий")) .font(.headline) .multilineTextAlignment(.center) Text(NSLocalizedString("Войдите с другого устройства, чтобы увидеть его здесь.", comment: "Подсказка при отсутствии активных сессий")) .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 24) } .listRowSeparator(.hidden) } private func sessionRow(for session: SessionViewData) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { Text(session.clientTypeDisplay) .font(.headline) if let ip = session.ipAddress, !ip.isEmpty { Label(ip, systemImage: "globe") .font(.subheadline) .foregroundColor(.secondary) } } Spacer() if session.isCurrent { Text(NSLocalizedString("Текущая", comment: "Маркер текущей сессии")) .font(.caption2.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Color.accentColor.opacity(0.15)) .foregroundColor(.accentColor) .clipShape(Capsule()) } } if let userAgent = session.userAgent, !userAgent.isEmpty { Text(userAgent) .font(.footnote) .foregroundColor(.secondary) .lineLimit(3) } VStack(alignment: .leading, spacing: 6) { Label(session.firstLoginText, systemImage: "calendar") .font(.caption) .foregroundColor(.secondary) Label(session.lastLoginText, systemImage: "arrow.clockwise") .font(.caption) .foregroundColor(.secondary) } } .padding(.vertical, 6) } @MainActor private func loadSessions(force: Bool = false) async { if isLoading && !force { return } isLoading = true loadError = nil do { let payloads = try await sessionsService.fetchSessions() sessions = payloads.map(SessionViewData.init) } catch { loadError = error.localizedDescription if AppConfig.DEBUG { print("[ActiveSessionsView] load sessions failed: \(error)") } } 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 { if !revokeInProgress { showRevokeConfirmation = true } } 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 { let id: UUID let ipAddress: String? let userAgent: String? let clientType: String let isActive: Bool let createdAt: Date let lastRefreshAt: Date let isCurrent: Bool init(payload: UserSessionPayload) { self.id = payload.id 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 } var clientTypeDisplay: String { let normalized = clientType.lowercased() switch normalized { case "mobile": return NSLocalizedString("Мобильное приложение", comment: "Тип сессии — мобильное приложение") case "web": return NSLocalizedString("Веб", comment: "Тип сессии — веб") case "desktop": return NSLocalizedString("Десктоп", comment: "Тип сессии — десктоп") case "bot": return NSLocalizedString("Бот", comment: "Тип сессии — бот") default: return clientType.capitalized } } var firstLoginText: String { let formatted = Self.dateFormatter.string(from: createdAt) return String(format: NSLocalizedString("Первый вход: %@", comment: "Дата первого входа в сессию"), formatted) } var lastLoginText: String { let formatted = Self.dateFormatter.string(from: lastRefreshAt) return String(format: NSLocalizedString("Последний вход: %@", comment: "Дата последнего входа в сессию"), formatted) } private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale.current formatter.timeZone = .current formatter.dateStyle = .medium formatter.timeStyle = .short return formatter }() } private struct SessionsAlert: Identifiable { let id = UUID() let title: String let message: String }