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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user