Compare commits

...

4 Commits

Author SHA1 Message Date
020aa8de5d change position to revoke all session 2025-10-24 10:29:20 +03:00
be6394f6fb add confirm revoke 2025-10-24 10:20:20 +03:00
cf5d2ad7fb patct sessions list 2025-10-24 10:17:15 +03:00
26534e88c1 add session list 2025-10-24 10:06:26 +03:00
4 changed files with 658 additions and 9 deletions

View File

@ -0,0 +1,265 @@
import Foundation
enum SessionsServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service decoding error")
}
}
}
struct UserSessionPayload: Decodable {
let id: UUID
let ipAddress: String?
let userAgent: String?
let clientType: String
let isActive: Bool
let createdAt: Date
let lastRefreshAt: Date
let isCurrent: Bool
}
private struct SessionsListPayload: Decodable {
let sessions: [UserSessionPayload]
}
final class SessionsService {
private let client: NetworkClient
private let decoder: JSONDecoder
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
}
func fetchSessions(completion: @escaping (Result<[UserSessionPayload], Error>) -> Void) {
client.request(
path: "/v1/auth/sessions/list",
method: .get,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<SessionsListPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список сессий.", comment: "Sessions service unexpected status")
completion(.failure(SessionsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.sessions))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[SessionsService] decode sessions 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 fetchSessions() async throws -> [UserSessionPayload] {
try await withCheckedThrowingContinuation { continuation in
fetchSessions { result in
continuation.resume(with: result)
}
}
}
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)
}
}
}
func revoke(sessionId: UUID, completion: @escaping (Result<String, Error>) -> Void) {
client.request(
path: "/v1/auth/sessions/revoke/\(sessionId.uuidString)",
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 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 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 revoke(sessionId: UUID) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
revoke(sessionId: sessionId) { result in
continuation.resume(with: result)
}
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
}
if let date = iso8601Simple.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Невозможно декодировать дату: \(string)"
)
}
private static func describeDecodingError(error: Error, data: Data) -> String {
var parts: [String] = []
if let decodingError = error as? DecodingError {
parts.append(decodingDescription(from: decodingError))
} else {
parts.append(error.localizedDescription)
}
if let payload = truncatedPayload(from: data) {
parts.append("payload=\(payload)")
}
return parts.joined(separator: "\n")
}
private static func decodingDescription(from error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .valueNotFound(let type, let context):
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
case .dataCorrupted(let context):
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
private static func codingPath(from context: DecodingError.Context) -> String {
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
return path.isEmpty ? "root" : path.joined(separator: ".")
}
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
if string.count <= limit {
return string
}
let index = string.index(string.startIndex, offsetBy: limit)
return String(string[string.startIndex..<index]) + ""
}
private static func errorMessage(from data: Data) -> String? {
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if let detail = apiError.detail, !detail.isEmpty {
return detail
}
if let message = apiError.data?.message, !message.isEmpty {
return message
}
}
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
return string
}
return nil
}
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Simple: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}

View File

@ -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" : {
@ -198,6 +198,7 @@
} }
}, },
"Активные сессии" : { "Активные сессии" : {
"comment" : "Заголовок экрана активных сессий",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -207,6 +208,9 @@
} }
} }
}, },
"Активные сессии не найдены" : {
"comment" : "Пустой список активных сессий"
},
"Аудио" : { "Аудио" : {
"comment" : "Audio message placeholder" "comment" : "Audio message placeholder"
}, },
@ -226,6 +230,9 @@
"Блокировка контакта \"%1$@\" появится позже." : { "Блокировка контакта \"%1$@\" появится позже." : {
"comment" : "Contacts block placeholder message" "comment" : "Contacts block placeholder message"
}, },
"Бот" : {
"comment" : "Тип сессии — бот"
},
"В чате пока нет сообщений." : { "В чате пока нет сообщений." : {
}, },
@ -240,6 +247,9 @@
} }
} }
}, },
"Веб" : {
"comment" : "Тип сессии — веб"
},
"Версия:" : { "Версия:" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -288,6 +298,9 @@
}, },
"Вложение" : { "Вложение" : {
},
"Войдите с другого устройства, чтобы увидеть его здесь." : {
"comment" : "Подсказка при отсутствии активных сессий"
}, },
"Войти" : { "Войти" : {
"localizations" : { "localizations" : {
@ -319,6 +332,9 @@
} }
} }
}, },
"Всего сессий" : {
"comment" : "Сводка по количеству сессий"
},
"Вы" : { "Вы" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -329,6 +345,9 @@
} }
} }
}, },
"Вы выйдете со всех устройств, кроме текущего." : {
"comment" : "Описание подтверждения завершения сессий"
},
"Выберите оценку — это поможет нам понять настроение." : { "Выберите оценку — это поможет нам понять настроение." : {
"comment" : "feedback: rating hint", "comment" : "feedback: rating hint",
"localizations" : { "localizations" : {
@ -360,7 +379,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" : {
@ -390,6 +409,9 @@
} }
} }
}, },
"Десктоп" : {
"comment" : "Тип сессии — десктоп"
},
"Добавить друзей" : { "Добавить друзей" : {
"comment" : "Add friends", "comment" : "Add friends",
"localizations" : { "localizations" : {
@ -407,6 +429,9 @@
"Добавьте контакты, чтобы быстрее выходить на связь." : { "Добавьте контакты, чтобы быстрее выходить на связь." : {
"comment" : "Contacts empty state subtitle" "comment" : "Contacts empty state subtitle"
}, },
"Другие устройства (%d)" : {
"comment" : "Заголовок секции других устройств с количеством"
},
"Другое" : { "Другое" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -432,10 +457,16 @@
"Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : { "Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : {
}, },
"Заглушка: Push-уведомления" : { "Завершить" : {
"comment" : "Подтверждение завершения других сессий"
}, },
"Заглушка: Активные сессии" : { "Завершить другие сессии" : {
"comment" : "Кнопка завершения других сессий"
},
"Завершить сессии на других устройствах?" : {
"comment" : "Заголовок подтверждения завершения сессий"
},
"Заглушка: Push-уведомления" : {
}, },
"Заглушка: Двухфакторная аутентификация" : { "Заглушка: Двухфакторная аутентификация" : {
@ -791,6 +822,9 @@
} }
} }
}, },
"Мобильное приложение" : {
"comment" : "Тип сессии — мобильное приложение"
},
"Мои загрузки" : { "Мои загрузки" : {
"comment" : "My Downloads", "comment" : "My Downloads",
"localizations" : { "localizations" : {
@ -950,6 +984,12 @@
"Не удалось выполнить поиск." : { "Не удалось выполнить поиск." : {
"comment" : "Search error fallback\nSearch service decoding error" "comment" : "Search error fallback\nSearch service decoding error"
}, },
"Не удалось завершить другие сессии." : {
"comment" : "Sessions service revoke-all unexpected status"
},
"Не удалось завершить сессию." : {
"comment" : "Sessions service revoke unexpected status"
},
"Не удалось загрузить историю чата." : { "Не удалось загрузить историю чата." : {
}, },
@ -959,6 +999,9 @@
"Не удалось загрузить профиль." : { "Не удалось загрузить профиль." : {
"comment" : "Profile service decoding error\nProfile unexpected status" "comment" : "Profile service decoding error\nProfile unexpected status"
}, },
"Не удалось загрузить список сессий." : {
"comment" : "Sessions service decoding error\nSessions service unexpected status"
},
"Не удалось загрузить список чатов." : { "Не удалось загрузить список чатов." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1313,7 +1356,7 @@
}, },
"Отмена" : { "Отмена" : {
"comment" : "Common cancel" "comment" : "Common cancel\nОбщий текст кнопки отмены"
}, },
"Отображаемое имя" : { "Отображаемое имя" : {
@ -1363,7 +1406,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" : {
@ -1522,6 +1565,9 @@
} }
} }
}, },
"Первый вход: %@" : {
"comment" : "Дата первого входа в сессию"
},
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : { "Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
"comment" : "FAQ answer: reset password" "comment" : "FAQ answer: reset password"
}, },
@ -1686,6 +1732,9 @@
}, },
"Попробуйте изменить запрос поиска." : { "Попробуйте изменить запрос поиска." : {
},
"Последний вход: %@" : {
"comment" : "Дата последнего входа в сессию"
}, },
"Похвала" : { "Похвала" : {
"comment" : "feedback category: praise", "comment" : "feedback category: praise",
@ -2044,6 +2093,9 @@
} }
} }
}, },
"Сессий на других устройствах: %d" : {
"comment" : "Количество сессий на других устройствах"
},
"Сессия истекла. Войдите снова." : { "Сессия истекла. Войдите снова." : {
"comment" : "Chat creation unauthorized\nSearch error unauthorized", "comment" : "Chat creation unauthorized\nSearch error unauthorized",
"localizations" : { "localizations" : {
@ -2196,6 +2248,15 @@
}, },
"Стикеры" : { "Стикеры" : {
},
"Текущая" : {
"comment" : "Маркер текущей сессии"
},
"Текущая сессия не найдена" : {
"comment" : "Сообщение об отсутствии текущей сессии"
},
"Текущая сессия останется активной" : {
"comment" : "Подсказка под кнопкой завершения других сессий"
}, },
"Тема: %@" : { "Тема: %@" : {
"comment" : "feedback: success category", "comment" : "feedback: success category",
@ -2402,6 +2463,9 @@
}, },
"Экспериментальная поддержка iOS 15" : { "Экспериментальная поддержка iOS 15" : {
},
"Это устройство" : {
"comment" : "Заголовок секции текущего устройства"
}, },
"Я ознакомился и принимаю правила сервиса" : { "Я ознакомился и принимаю правила сервиса" : {

View File

@ -0,0 +1,320 @@
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
}

View File

@ -35,8 +35,8 @@ struct SettingsView: View {
NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) { NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) {
Label("Двухфакторная аутентификация", systemImage: "lock.shield") Label("Двухфакторная аутентификация", systemImage: "lock.shield")
} }
NavigationLink(destination: Text("Заглушка: Активные сессии")) { NavigationLink(destination: ActiveSessionsView()) {
Label("Активные сессии", systemImage: "iphone") Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone")
} }
} }