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