Commit eed03517da

eed03517daa637a40734ecb9afb445eeb89706e6

parent: c2f1ecce1f

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 04:52 UTC

feat: show the meta.sr.ht audit log

auditLog existed in the API but was never called, so the record of what has
happened to your account — logins, key changes, the addresses they came from —
was web-only.

Sits under the tokens in Profile and loads on demand for the same reason they
do: an audit log is something you go looking for, not something worth a request
on every profile view. One page, newest first; the archive stays on meta.sr.ht.

Its errors are kept separate from the shared `error` so a failed audit fetch
cannot bury a profile save failure, and vice versa.

Layout: unified · split

Hutch/Models/Meta.swift +10
@@ -69,3 +69,13 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable {
69 let comment: String? 69 let comment: String?
70 let grants: String? 70 let grants: String?
71} 71}
72
73/// One entry in meta.sr.ht's audit log: a security-relevant action on the
74/// account, with the address it came from.
75struct AuditLogEntry: Codable, Sendable, Identifiable {
76 let id: Int
77 let created: Date
78 let ipAddress: String
79 let eventType: String
80 let details: String?
81}
Hutch/Views/More/ProfileView.swift +56
@@ -84,6 +84,7 @@ struct ProfileView: View {
84 sshKeysSection(viewModel) 84 sshKeysSection(viewModel)
85 pgpKeysSection(viewModel) 85 pgpKeysSection(viewModel)
86 patSection(viewModel) 86 patSection(viewModel)
87 auditLogSection(viewModel)
87 } 88 }
88 } 89 }
89 .themedList() 90 .themedList()
@@ -432,6 +433,61 @@ struct ProfileView: View {
432 } 433 }
433 } 434 }
434 } 435 }
436
437 /// Loaded on demand, like the tokens above — an audit log is something you go
438 /// looking for, not something worth a request on every profile view.
439 @ViewBuilder
440 private func auditLogSection(_ viewModel: SettingsViewModel) -> some View {
441 Section {
442 if viewModel.isLoadingAuditLog {
443 HStack {
444 Spacer()
445 ProgressView()
446 Spacer()
447 }
448 .themedRow()
449 } else if let error = viewModel.auditLogError {
450 Text(error)
451 .font(.caption)
452 .foregroundStyle(.red)
453 .themedRow()
454 } else if viewModel.auditLog.isEmpty {
455 Button("Load Audit Log") {
456 Task { await viewModel.loadAuditLog() }
457 }
458 .themedRow()
459 } else {
460 ForEach(viewModel.auditLog) { entry in
461 VStack(alignment: .leading, spacing: 4) {
462 Text(entry.eventType)
463 .font(.subheadline)
464
465 if let details = entry.details, !details.isEmpty {
466 Text(details)
467 .font(.caption2)
468 .foregroundStyle(.secondary)
469 .lineLimit(3)
470 }
471
472 HStack(spacing: 12) {
473 Text(entry.created.relativeDescription)
474 Text(entry.ipAddress)
475 .monospaced()
476 }
477 .font(.caption2)
478 .foregroundStyle(.tertiary)
479 }
480 .accessibilityElement(children: .combine)
481 .accessibilityLabel("\(entry.eventType), \(entry.created.relativeDescription), from \(entry.ipAddress)")
482 }
483 .themedRow()
484 }
485 } header: {
486 Text("Audit Log")
487 } footer: {
488 Text("Recent security-relevant activity on your account, newest first. The full log lives on meta.sr.ht.")
489 }
490 }
435} 491}
436 492
437private struct ProfileBioView: View { 493private struct ProfileBioView: View {
Hutch/Views/Settings/SettingsViewModel.swift +42
@@ -43,6 +43,15 @@ private struct PATListResponse: Decodable, Sendable {
43 let personalAccessTokens: [PersonalAccessToken] 43 let personalAccessTokens: [PersonalAccessToken]
44} 44}
45 45
46private struct AuditLogResponse: Decodable, Sendable {
47 let auditLog: AuditLogPage
48}
49
50private struct AuditLogPage: Decodable, Sendable {
51 let results: [AuditLogEntry]
52 let cursor: String?
53}
54
46// MARK: - View Model 55// MARK: - View Model
47 56
48@Observable 57@Observable
@@ -53,6 +62,11 @@ final class SettingsViewModel {
53 private(set) var sshKeys: [SSHKey] = [] 62 private(set) var sshKeys: [SSHKey] = []
54 private(set) var pgpKeys: [PGPKey] = [] 63 private(set) var pgpKeys: [PGPKey] = []
55 private(set) var personalAccessTokens: [PersonalAccessToken] = [] 64 private(set) var personalAccessTokens: [PersonalAccessToken] = []
65 private(set) var auditLog: [AuditLogEntry] = []
66 private(set) var isLoadingAuditLog = false
67 /// Kept apart from `error` so a failed audit fetch cannot bury a profile
68 /// save failure, and vice versa.
69 var auditLogError: String?
56 70
57 private(set) var isLoading = false 71 private(set) var isLoading = false
58 private(set) var isLoadingPATs = false 72 private(set) var isLoadingPATs = false
@@ -139,6 +153,12 @@ final class SettingsViewModel {
139 } 153 }
140 """ 154 """
141 155
156 private static let auditLogQuery = """
157 query auditLog {
158 auditLog { results { id created ipAddress eventType details } }
159 }
160 """
161
142 private static let personalAccessTokensQuery = """ 162 private static let personalAccessTokensQuery = """
143 query personalAccessTokens { 163 query personalAccessTokens {
144 personalAccessTokens { id issued expires comment grants } 164 personalAccessTokens { id issued expires comment grants }
@@ -393,4 +413,26 @@ final class SettingsViewModel {
393 isLoadingPATs = false 413 isLoadingPATs = false
394 } 414 }
395 415
416 // MARK: - Audit Log
417
418 /// Loads the most recent audit entries.
419 ///
420 /// Deliberately one page: this is a glanceable "has anything happened to my
421 /// account" surface, not an archive. The full log is on meta.sr.ht.
422 func loadAuditLog() async {
423 guard !isLoadingAuditLog else { return }
424 isLoadingAuditLog = true
425 defer { isLoadingAuditLog = false }
426
427 do {
428 let result = try await client.execute(
429 service: .meta,
430 query: Self.auditLogQuery,
431 responseType: AuditLogResponse.self
432 )
433 auditLog = result.auditLog.results
434 } catch {
435 auditLogError = error.userFacingMessage
436 }
437 }
396} 438}