From dc780e420f605f879c8d44ec2f734343179d6b2e Mon Sep 17 00:00:00 2001 From: psmattas Date: Tue, 18 Aug 2026 14:19:50 +0100 Subject: [PATCH] feat(settings): API & Access section (personal keys, read-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists existing personal API keys via apiKeys.list (name, masked last4, created/last-used dates) with a link to Outline's developer docs. Creation/revocation deferred per instruction — read-only for this pass. --- Outpost/Features/Account/SettingsView.swift | 85 +++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 3c0e14b..938246e 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -41,6 +41,9 @@ struct SettingsView: View { @State private var deleteAccountErrorMessage: String? @State private var isSavingNotifications = false @State private var notificationsErrorMessage: String? + @State private var apiKeys: [OutlineAPIKey] = [] + @State private var isLoadingApiKeys = false + @State private var apiKeysErrorMessage: String? /// Full Local Sync and cache-clearing both need a real connection to be /// safe — clearing while offline (or letting Full Local Sync think it @@ -83,6 +86,7 @@ struct SettingsView: View { case .preferences: preferencesDetail case .notifications: notificationsDetail case .passkeys: passkeysDetail + case .apiAccess: apiAccessDetail case .offlineSync: offlineSyncDetail case .advanced: advancedDetail case .about: aboutDetail @@ -739,6 +743,83 @@ struct SettingsView: View { } } + // MARK: - API & Access + + private var apiAccessDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader + Text("Create personal API keys to authenticate with the API and programmatically control your workspace's data. For more details see the [developer documentation](https://www.getoutline.com/developers).") + .font(.subheadline) + .foregroundStyle(.secondary) + .tint(.accentColor) + .frame(maxWidth: 480, alignment: .leading) + + Divider().frame(maxWidth: 480) + + Text("Personal keys") + .font(.headline) + + apiKeysList + } + .task { await refreshApiKeys() } + } + + @ViewBuilder + private var apiKeysList: some View { + if isLoadingApiKeys && apiKeys.isEmpty { + ProgressView().controlSize(.small) + } else if let apiKeysErrorMessage { + Text(apiKeysErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } else if apiKeys.isEmpty { + Text("No personal API keys yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(apiKeys) { key in + apiKeyRow(key) + if key.id != apiKeys.last?.id { + Divider() + } + } + } + .frame(maxWidth: 480, alignment: .leading) + } + } + + private func apiKeyRow(_ key: OutlineAPIKey) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(key.name) + .font(.callout.weight(.medium)) + Spacer() + if let last4 = key.last4 { + Text("••••••••\(last4)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + Text("Created \(formattedDate(key.createdAt))\(key.lastActiveAt.map { " — last used \(formattedDate($0))" } ?? "")") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + + private func refreshApiKeys() async { + guard let apiClient = session.apiClient else { return } + isLoadingApiKeys = true + defer { isLoadingApiKeys = false } + do { + apiKeys = try await apiClient.listApiKeys(ListApiKeysRequest()) + apiKeysErrorMessage = nil + } catch { + apiKeysErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load your API keys.") + } + } + // MARK: - Offline & Sync private var offlineSyncDetail: some View { @@ -1012,6 +1093,10 @@ struct SettingsView: View { .font(.callout) } + private func formattedDate(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .omitted) + } + private func formattedBytes(_ bytes: Int) -> String { ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) }