383 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			383 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
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
 | 
						||
    @State private var sessionPendingRevoke: SessionViewData?
 | 
						||
    @State private var revokingSessionIds: Set<UUID> = []
 | 
						||
 | 
						||
    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
 | 
						||
                            let isRevoking = isRevoking(session: session)
 | 
						||
 | 
						||
                            sessionRow(for: session, isRevoking: isRevoking)
 | 
						||
                                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
 | 
						||
                                    Button(role: .destructive) {
 | 
						||
                                        sessionPendingRevoke = session
 | 
						||
                                    } label: {
 | 
						||
                                        Label(NSLocalizedString("Завершить", comment: "Кнопка завершения конкретной сессии"), systemImage: "trash")
 | 
						||
                                    }
 | 
						||
                                    .disabled(isRevoking)
 | 
						||
                                }
 | 
						||
                                .disabled(isRevoking)
 | 
						||
                        }
 | 
						||
                    }
 | 
						||
                }
 | 
						||
            }
 | 
						||
        }
 | 
						||
        .navigationTitle(NSLocalizedString("Активные сессии", comment: "Заголовок экрана активных сессий"))
 | 
						||
        .navigationBarTitleDisplayMode(.inline)
 | 
						||
        .task {
 | 
						||
            await loadSessions()
 | 
						||
        }
 | 
						||
        .refreshable {
 | 
						||
            await loadSessions(force: true)
 | 
						||
        }
 | 
						||
        .confirmationDialog(
 | 
						||
            NSLocalizedString("Завершить эту сессию?", comment: "Заголовок подтверждения завершения отдельной сессии"),
 | 
						||
            isPresented: Binding(
 | 
						||
                get: { sessionPendingRevoke != nil },
 | 
						||
                set: { if !$0 { sessionPendingRevoke = nil } }
 | 
						||
            ),
 | 
						||
            presenting: sessionPendingRevoke
 | 
						||
        ) { session in
 | 
						||
            Button(NSLocalizedString("Завершить", comment: "Подтверждение завершения конкретной сессии"), role: .destructive) {
 | 
						||
                sessionPendingRevoke = nil
 | 
						||
                Task { await revoke(session: session) }
 | 
						||
            }
 | 
						||
            Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {
 | 
						||
                sessionPendingRevoke = nil
 | 
						||
            }
 | 
						||
        } message: { _ in
 | 
						||
            Text(NSLocalizedString("Вы выйдете из выбранной сессии.", comment: "Описание подтверждения завершения конкретной сессии"))
 | 
						||
        }
 | 
						||
        .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, isRevoking: Bool = false) -> 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())
 | 
						||
                } else if isRevoking {
 | 
						||
                    ProgressView()
 | 
						||
                        .progressViewStyle(.circular)
 | 
						||
                }
 | 
						||
            }
 | 
						||
 | 
						||
            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
 | 
						||
            )
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    @MainActor
 | 
						||
    private func revoke(session: SessionViewData) async {
 | 
						||
        guard !session.isCurrent, !isRevoking(session: session) else {
 | 
						||
            return
 | 
						||
        }
 | 
						||
 | 
						||
        revokingSessionIds.insert(session.id)
 | 
						||
        defer { revokingSessionIds.remove(session.id) }
 | 
						||
 | 
						||
        do {
 | 
						||
            let message = try await sessionsService.revoke(sessionId: session.id)
 | 
						||
            sessions.removeAll { $0.id == session.id }
 | 
						||
            activeAlert = SessionsAlert(
 | 
						||
                title: NSLocalizedString("Готово", comment: "Заголовок успешного уведомления"),
 | 
						||
                message: message
 | 
						||
            )
 | 
						||
        } catch {
 | 
						||
            activeAlert = SessionsAlert(
 | 
						||
                title: NSLocalizedString("Ошибка", comment: "Заголовок сообщения об ошибке"),
 | 
						||
                message: error.localizedDescription
 | 
						||
            )
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    private func isRevoking(session: SessionViewData) -> Bool {
 | 
						||
        revokingSessionIds.contains(session.id)
 | 
						||
    }
 | 
						||
 | 
						||
    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
 | 
						||
}
 |