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 | 69 | let comment: String? |
| 70 | 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. |
| 75 | struct 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 | 84 | sshKeysSection(viewModel) |
| 85 | 85 | pgpKeysSection(viewModel) |
| 86 | 86 | patSection(viewModel) |
| 87 | auditLogSection(viewModel) |
| 87 | 88 | } |
| 88 | 89 | } |
| 89 | 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 | |
| 437 | 493 | private struct ProfileBioView: View { |
Hutch/Views/Settings/SettingsViewModel.swift
+42
| @@ -43,6 +43,15 @@ private struct PATListResponse: Decodable, Sendable { |
| 43 | 43 | let personalAccessTokens: [PersonalAccessToken] |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | private struct AuditLogResponse: Decodable, Sendable { |
| 47 | let auditLog: AuditLogPage |
| 48 | } |
| 49 | |
| 50 | private struct AuditLogPage: Decodable, Sendable { |
| 51 | let results: [AuditLogEntry] |
| 52 | let cursor: String? |
| 53 | } |
| 54 | |
| 46 | 55 | // MARK: - View Model |
| 47 | 56 | |
| 48 | 57 | @Observable |
| @@ -53,6 +62,11 @@ final class SettingsViewModel { |
| 53 | 62 | private(set) var sshKeys: [SSHKey] = [] |
| 54 | 63 | private(set) var pgpKeys: [PGPKey] = [] |
| 55 | 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 | 71 | private(set) var isLoading = false |
| 58 | 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 | 162 | private static let personalAccessTokensQuery = """ |
| 143 | 163 | query personalAccessTokens { |
| 144 | 164 | personalAccessTokens { id issued expires comment grants } |
| @@ -393,4 +413,26 @@ final class SettingsViewModel { |
| 393 | 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 | } |