krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

f3627deef0848b95a083d9e16468b0f8484d784e

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-16T04:52:45Z

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.
 Hutch/Models/Meta.swift                      | 10 +++++
 Hutch/Views/More/ProfileView.swift           | 56 ++++++++++++++++++++++++++++
 Hutch/Views/Settings/SettingsViewModel.swift | 42 +++++++++++++++++++++
 3 files changed, 108 insertions(+)

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