diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 5de1f70..b5ca05f 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -44,6 +44,15 @@ struct SettingsView: View { @State private var apiKeys: [OutlineAPIKey] = [] @State private var isLoadingApiKeys = false @State private var apiKeysErrorMessage: String? + @State private var isShowingCreateApiKey = false + @State private var newApiKeyName = "" + @State private var newApiKeyExpiration: ApiKeyExpiration = .noExpiration + @State private var isCreatingApiKey = false + @State private var createApiKeyErrorMessage: String? + @State private var revealedApiKey: RevealedApiKey? + @State private var didCopyRevealedKey = false + @State private var apiKeyPendingDeletion: OutlineAPIKey? + @State private var deletingApiKeyId: 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 @@ -755,12 +764,156 @@ struct SettingsView: View { Divider().frame(maxWidth: 480) - Text("Personal keys") - .font(.headline) + HStack { + Text("Personal keys") + .font(.headline) + Spacer() + Button("New API Key…") { isShowingCreateApiKey = true } + .disabled(!isEffectivelyOnline) + } + .frame(maxWidth: 480) apiKeysList } .task { await refreshApiKeys() } + .sheet(isPresented: $isShowingCreateApiKey) { + createApiKeySheet + } + .sheet(item: $revealedApiKey) { revealed in + apiKeyRevealSheet(revealed) + } + .confirmationDialog( + "Delete API Key?", + isPresented: Binding( + get: { apiKeyPendingDeletion != nil }, + set: { if !$0 { apiKeyPendingDeletion = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let key = apiKeyPendingDeletion { + Task { await deleteApiKey(key) } + } + } + Button("Cancel", role: .cancel) { apiKeyPendingDeletion = nil } + } message: { + if let name = apiKeyPendingDeletion?.name { + Text("Any scripts or integrations using \"\(name)\" will stop working immediately.") + } + } + } + + /// Shown exactly once, immediately after creation — Outline never + /// returns the plaintext value again after this response (confirmed + /// live: `apiKeys.list` omits it), so this app enforces the same + /// "copy it now or lose it" rule the web app does, not just for show. + private func apiKeyRevealSheet(_ revealed: RevealedApiKey) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("API Key Created") + .font(.headline) + Text("Copy this key now — treat it like a password. For security, it will only be shown this once.") + .font(.callout) + .foregroundStyle(.secondary) + + HStack { + Text(revealed.value) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(didCopyRevealedKey ? "Copied" : "Copy") { + copyToPasteboard(revealed.value) + didCopyRevealedKey = true + } + } + .padding(10) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Label( + "Be careful when handling your keys, as they allow full access to your data — treat them like passwords.", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.orange) + + HStack { + Spacer() + Button(didCopyRevealedKey ? "Done" : "I've Copied It") { + revealedApiKey = nil + } + .buttonStyle(.borderedProminent) + .disabled(!didCopyRevealedKey) + } + } + .padding(24) + .frame(width: 420) + .interactiveDismissDisabled(!didCopyRevealedKey) + // Belt-and-suspenders: however this sheet actually closes, the + // plaintext key is gone from memory the moment it's gone from + // screen — not just visually hidden behind dismissed UI state. + .onDisappear { + revealedApiKey = nil + didCopyRevealedKey = false + } + } + + private func copyToPasteboard(_ string: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + } + + private var createApiKeySheet: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New API Key") + .font(.headline) + + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) + TextField("e.g. My Script", text: $newApiKeyName) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await createApiKey() } } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Expiration") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Expiration", selection: $newApiKeyExpiration) { + ForEach(ApiKeyExpiration.allCases) { option in + Text(option.label).tag(option) + } + } + .labelsHidden() + } + + if let createApiKeyErrorMessage { + Text(createApiKeyErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel") { + isShowingCreateApiKey = false + newApiKeyName = "" + newApiKeyExpiration = .noExpiration + createApiKeyErrorMessage = nil + } + if isCreatingApiKey { + ProgressView().controlSize(.small) + } else { + Button("Create") { Task { await createApiKey() } } + .buttonStyle(.borderedProminent) + .disabled(newApiKeyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .padding(24) + .frame(width: 360) } @ViewBuilder @@ -789,24 +942,78 @@ struct SettingsView: View { } 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) + HStack { + 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) + } + if deletingApiKeyId == key.id { + ProgressView().controlSize(.small) + } else { + Button { + apiKeyPendingDeletion = key + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + .foregroundStyle(.red) + .disabled(!isEffectivelyOnline) } - Text("Created \(formattedDate(key.createdAt))\(key.lastActiveAt.map { " — last used \(formattedDate($0))" } ?? "")") - .font(.caption) - .foregroundStyle(.secondary) } .padding(.vertical, 8) } + private func createApiKey() async { + guard let apiClient = session.apiClient else { return } + let trimmedName = newApiKeyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + isCreatingApiKey = true + defer { isCreatingApiKey = false } + do { + let created = try await apiClient.createApiKey( + CreateApiKeyRequest(name: trimmedName, expiresAt: newApiKeyExpiration.expiresAtDate) + ) + isShowingCreateApiKey = false + newApiKeyName = "" + newApiKeyExpiration = .noExpiration + createApiKeyErrorMessage = nil + // The plaintext `value` only ever exists on this one response — + // held only in `revealedApiKey`'s short lifetime, never merged + // into the persisted `apiKeys` list (refreshed from the server + // right after, which never returns it). + if let value = created.value { + revealedApiKey = RevealedApiKey(name: created.name, value: value) + } + await refreshApiKeys() + } catch { + createApiKeyErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create this API key.") + } + } + + private func deleteApiKey(_ key: OutlineAPIKey) async { + guard let apiClient = session.apiClient else { return } + apiKeyPendingDeletion = nil + deletingApiKeyId = key.id + defer { deletingApiKeyId = nil } + do { + try await apiClient.deleteApiKey(id: key.id) + await refreshApiKeys() + } catch { + apiKeysErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this API key.") + } + } + private func refreshApiKeys() async { guard let apiClient = session.apiClient else { return } isLoadingApiKeys = true @@ -1234,4 +1441,41 @@ private struct PickedPhoto: Identifiable { let id = UUID() let image: NSImage } + +/// The one-time plaintext value from a just-created API key, plus enough +/// to label the reveal sheet — deliberately not `OutlineAPIKey` itself, so +/// nothing holding a reference to "the created key" for other purposes can +/// accidentally end up holding the secret too. +private struct RevealedApiKey: Identifiable { + let id = UUID() + let name: String + let value: String +} + +private enum ApiKeyExpiration: String, CaseIterable, Identifiable { + case noExpiration, oneMonth, threeMonths, sixMonths, oneYear + + var id: String { rawValue } + + var label: String { + switch self { + case .noExpiration: return "No expiration" + case .oneMonth: return "1 month" + case .threeMonths: return "3 months" + case .sixMonths: return "6 months" + case .oneYear: return "1 year" + } + } + + var expiresAtDate: Date? { + let calendar = Calendar.current + switch self { + case .noExpiration: return nil + case .oneMonth: return calendar.date(byAdding: .month, value: 1, to: Date()) + case .threeMonths: return calendar.date(byAdding: .month, value: 3, to: Date()) + case .sixMonths: return calendar.date(byAdding: .month, value: 6, to: Date()) + case .oneYear: return calendar.date(byAdding: .year, value: 1, to: Date()) + } + } +} #endif