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. |
| |
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 | 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 | |
| 437 | private struct ProfileBioView: View { |
493 | private 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 | |
| |
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 | // 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 | } |