feat(settings): API & Access section (personal keys, read-only)

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.
This commit is contained in:
2026-08-18 14:19:50 +01:00
parent 6eb780d1bd
commit dc780e420f
@@ -41,6 +41,9 @@ struct SettingsView: View {
@State private var deleteAccountErrorMessage: String? @State private var deleteAccountErrorMessage: String?
@State private var isSavingNotifications = false @State private var isSavingNotifications = false
@State private var notificationsErrorMessage: String? @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 /// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it /// safe clearing while offline (or letting Full Local Sync think it
@@ -83,6 +86,7 @@ struct SettingsView: View {
case .preferences: preferencesDetail case .preferences: preferencesDetail
case .notifications: notificationsDetail case .notifications: notificationsDetail
case .passkeys: passkeysDetail case .passkeys: passkeysDetail
case .apiAccess: apiAccessDetail
case .offlineSync: offlineSyncDetail case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail case .advanced: advancedDetail
case .about: aboutDetail 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 // MARK: - Offline & Sync
private var offlineSyncDetail: some View { private var offlineSyncDetail: some View {
@@ -1012,6 +1093,10 @@ struct SettingsView: View {
.font(.callout) .font(.callout)
} }
private func formattedDate(_ date: Date) -> String {
date.formatted(date: .abbreviated, time: .omitted)
}
private func formattedBytes(_ bytes: Int) -> String { private func formattedBytes(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
} }