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
|
||
}
|