aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/Models/Meta.swift10
-rw-r--r--Hutch/Views/More/ProfileView.swift56
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift42
3 files changed, 108 insertions, 0 deletions
diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift
index 91bd7e3..f52fc76 100644
--- a/Hutch/Models/Meta.swift
+++ b/Hutch/Models/Meta.swift
@@ -69,3 +69,13 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable {
let comment: String?
let grants: String?
}
+
+/// One entry in meta.sr.ht's audit log: a security-relevant action on the
+/// account, with the address it came from.
+struct AuditLogEntry: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let ipAddress: String
+ let eventType: String
+ let details: String?
+}
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index 45f7d06..fc1ed2f 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -84,6 +84,7 @@ struct ProfileView: View {
sshKeysSection(viewModel)
pgpKeysSection(viewModel)
patSection(viewModel)
+ auditLogSection(viewModel)
}
}
.themedList()
@@ -432,6 +433,61 @@ struct ProfileView: View {
}
}
}
+
+ /// Loaded on demand, like the tokens above — an audit log is something you go
+ /// looking for, not something worth a request on every profile view.
+ @ViewBuilder
+ private func auditLogSection(_ viewModel: SettingsViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingAuditLog {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .themedRow()
+ } else if let error = viewModel.auditLogError {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .themedRow()
+ } else if viewModel.auditLog.isEmpty {
+ Button("Load Audit Log") {
+ Task { await viewModel.loadAuditLog() }
+ }
+ .themedRow()
+ } else {
+ ForEach(viewModel.auditLog) { entry in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.eventType)
+ .font(.subheadline)
+
+ if let details = entry.details, !details.isEmpty {
+ Text(details)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(3)
+ }
+
+ HStack(spacing: 12) {
+ Text(entry.created.relativeDescription)
+ Text(entry.ipAddress)
+ .monospaced()
+ }
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(entry.eventType), \(entry.created.relativeDescription), from \(entry.ipAddress)")
+ }
+ .themedRow()
+ }
+ } header: {
+ Text("Audit Log")
+ } footer: {
+ Text("Recent security-relevant activity on your account, newest first. The full log lives on meta.sr.ht.")
+ }
+ }
}
private struct ProfileBioView: View {
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index edfaad4..8536e60 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -43,6 +43,15 @@ private struct PATListResponse: Decodable, Sendable {
let personalAccessTokens: [PersonalAccessToken]
}
+private struct AuditLogResponse: Decodable, Sendable {
+ let auditLog: AuditLogPage
+}
+
+private struct AuditLogPage: Decodable, Sendable {
+ let results: [AuditLogEntry]
+ let cursor: String?
+}
+
// MARK: - View Model
@Observable
@@ -53,6 +62,11 @@ final class SettingsViewModel {
private(set) var sshKeys: [SSHKey] = []
private(set) var pgpKeys: [PGPKey] = []
private(set) var personalAccessTokens: [PersonalAccessToken] = []
+ private(set) var auditLog: [AuditLogEntry] = []
+ private(set) var isLoadingAuditLog = false
+ /// Kept apart from `error` so a failed audit fetch cannot bury a profile
+ /// save failure, and vice versa.
+ var auditLogError: String?
private(set) var isLoading = false
private(set) var isLoadingPATs = false
@@ -139,6 +153,12 @@ final class SettingsViewModel {
}
"""
+ private static let auditLogQuery = """
+ query auditLog {
+ auditLog { results { id created ipAddress eventType details } }
+ }
+ """
+
private static let personalAccessTokensQuery = """
query personalAccessTokens {
personalAccessTokens { id issued expires comment grants }
@@ -393,4 +413,26 @@ final class SettingsViewModel {
isLoadingPATs = false
}
+ // MARK: - Audit Log
+
+ /// Loads the most recent audit entries.
+ ///
+ /// Deliberately one page: this is a glanceable "has anything happened to my
+ /// account" surface, not an archive. The full log is on meta.sr.ht.
+ func loadAuditLog() async {
+ guard !isLoadingAuditLog else { return }
+ isLoadingAuditLog = true
+ defer { isLoadingAuditLog = false }
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.auditLogQuery,
+ responseType: AuditLogResponse.self
+ )
+ auditLog = result.auditLog.results
+ } catch {
+ auditLogError = error.userFacingMessage
+ }
+ }
}