Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
710a6617f5
|
||
|
|
55c23afaff
|
||
|
|
f07c5055fa
|
||
|
|
8af860cd7e
|
||
|
|
78e3566a8e
|
||
|
|
64e970fc92
|
||
|
|
1e541979e9
|
||
|
|
26b0d7b118
|
||
|
|
45c950d474
|
||
|
|
044a706bd4
|
||
|
|
fa22ac616b
|
||
|
|
adfa477ecf
|
||
|
|
dc780e420f
|
||
|
|
6eb780d1bd
|
||
|
|
78a0e31897
|
||
|
|
e3ad8650c6
|
@@ -375,6 +375,22 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await live.listApiKeys(request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await live.createApiKey(request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await live.deleteApiKey(id: id)
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await live.installationInfo()
|
||||
}
|
||||
|
||||
// MARK: - Sync management (Settings surface)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
|
||||
@@ -94,4 +94,15 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
|
||||
/// Settings → API & Access.
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
|
||||
/// The returned `OutlineAPIKey.value` is the only time the full
|
||||
/// plaintext key is ever available — the caller is responsible for
|
||||
/// displaying it once and then discarding it.
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
|
||||
func deleteApiKey(id: String) async throws
|
||||
|
||||
/// Settings → Installation. Self-hosted server version info.
|
||||
func installationInfo() async throws -> OutlineInstallationInfo
|
||||
}
|
||||
|
||||
@@ -310,6 +310,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await post("apiKeys.list", body: request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await post("apiKeys.create", body: request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await post("installation.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||
/// of the actual token are ever returned by the server — there's no way to
|
||||
/// see a full key again after creation, matching every other API-key UI
|
||||
/// convention.
|
||||
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let last4: String?
|
||||
public let scope: [String]?
|
||||
public let createdAt: Date
|
||||
public let expiresAt: Date?
|
||||
public let lastActiveAt: Date?
|
||||
/// The full plaintext key — present *only* in `apiKeys.create`'s
|
||||
/// response, confirmed live: `apiKeys.list` never includes it, matching
|
||||
/// "shown once at creation" being enforced server-side, not just a
|
||||
/// client-side UI convention this app has to uphold on its own.
|
||||
public let value: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
last4: String? = nil,
|
||||
scope: [String]? = nil,
|
||||
createdAt: Date,
|
||||
expiresAt: Date? = nil,
|
||||
lastActiveAt: Date? = nil,
|
||||
value: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.last4 = last4
|
||||
self.scope = scope
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = expiresAt
|
||||
self.lastActiveAt = lastActiveAt
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||
/// response) isn't modeled here — not used by this app's Installation
|
||||
/// settings page.
|
||||
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let latestVersion: String
|
||||
public let versionsBehind: Int
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
|
||||
/// literal `null`) confirmed live to mean no expiration — Swift's
|
||||
/// synthesized `Encodable` already omits `nil` optionals via
|
||||
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
|
||||
/// `UpdateUserAvatarRequest` needed one for the opposite case.
|
||||
public struct CreateApiKeyRequest: Encodable, Sendable {
|
||||
public let name: String
|
||||
public let expiresAt: Date?
|
||||
/// `nil`/omitted grants full access — confirmed live (every key created
|
||||
/// without a scope came back with unrestricted access). A specific
|
||||
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
|
||||
public let scope: [String]?
|
||||
|
||||
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
|
||||
self.name = name
|
||||
self.expiresAt = expiresAt
|
||||
self.scope = scope
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat
|
||||
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
|
||||
/// `pagination` object that only appears in list *responses*.
|
||||
public struct ListApiKeysRequest: Encodable, Sendable {
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
|
||||
public init(offset: Int = 0, limit: Int = 25) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,10 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func deleteAccount() async throws { throw NotStubbed() }
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||
}
|
||||
|
||||
private struct StubTransportError: Error {}
|
||||
|
||||
@@ -1229,6 +1229,146 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertNil(sentBody?["eventType"])
|
||||
}
|
||||
|
||||
func testListApiKeysDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"pagination": { "limit": 25, "offset": 0 },
|
||||
"data": [
|
||||
{
|
||||
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||
"name": "Outpost",
|
||||
"scope": null,
|
||||
"last4": "dl1h",
|
||||
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||
|
||||
XCTAssertEqual(keys.count, 1)
|
||||
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||
XCTAssertNil(keys.first?.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||
}
|
||||
|
||||
func testInstallationInfoDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||
"policies": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let info = try await client.installationInfo()
|
||||
|
||||
XCTAssertEqual(info.version, "1.9.2")
|
||||
XCTAssertEqual(info.versionsBehind, 0)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||
}
|
||||
|
||||
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
|
||||
"name": "test",
|
||||
"scope": null,
|
||||
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
|
||||
"last4": "0DGv",
|
||||
"createdAt": "2026-08-18T15:39:29.872Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
|
||||
|
||||
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
|
||||
XCTAssertNil(key.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["name"] as? String, "test")
|
||||
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
|
||||
XCTAssertNil(sentBody?["scope"])
|
||||
}
|
||||
|
||||
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
|
||||
"name": "Test",
|
||||
"scope": null,
|
||||
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
|
||||
"last4": "cLts",
|
||||
"createdAt": "2026-08-18T15:38:22.928Z",
|
||||
"expiresAt": "2026-11-16T23:59:59.999Z",
|
||||
"lastActiveAt": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
|
||||
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
|
||||
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertNotNil(sentBody?["expiresAt"])
|
||||
}
|
||||
|
||||
func testDeleteApiKeySendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||
}
|
||||
|
||||
func testDeleteAccountSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.3;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -464,7 +464,7 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.3;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -14,17 +14,7 @@ struct AboutInfoView: View {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||
}
|
||||
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
private let releaseStage = "ALPHA"
|
||||
|
||||
var versionString: String {
|
||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
||||
}
|
||||
var versionString: String { OutpostVersion.fullVersionString }
|
||||
|
||||
private var copyrightYear: String {
|
||||
String(Calendar.current.component(.year, from: Date()))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Swapped into the real sidebar's content slot (search field, collections
|
||||
/// tree, account footer) while Settings is open — same sidebar, different
|
||||
@@ -8,13 +9,26 @@ import SwiftUI
|
||||
/// back.
|
||||
///
|
||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||
/// header at the top, then Outline's own Account/Workspace/Integrations &
|
||||
/// Installation groups, matching the settings page structure of the
|
||||
/// Outline web app.
|
||||
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||
/// the settings page structure of the Outline web app. Outline's own server
|
||||
/// version has no dedicated section (there used to be an Integrations &
|
||||
/// Installation category for just that) — it's cheap enough to show
|
||||
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||
/// version.
|
||||
struct SettingsSidebarList: View {
|
||||
@Binding var selection: SettingsSection?
|
||||
let onDone: () -> Void
|
||||
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var outlineVersion: String?
|
||||
|
||||
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
@@ -37,7 +51,21 @@ struct SettingsSidebarList: View {
|
||||
}
|
||||
} header: {
|
||||
if let title = category.title {
|
||||
Text(title)
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
// Not hardcoded to `.workspace` specifically —
|
||||
// stays correct on its own as sections get
|
||||
// built, only shows while every section in
|
||||
// the category is still `!isImplemented`.
|
||||
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||
Text("Coming Soon")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,12 +74,42 @@ struct SettingsSidebarList: View {
|
||||
|
||||
Divider()
|
||||
|
||||
versionFooter
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Done", action: onDone)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
}
|
||||
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||
// this automatically instead of leaving the footer stuck on
|
||||
// whatever it last knew, or blank, until Settings is reopened.
|
||||
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Outpost \(OutpostVersion.displayString)")
|
||||
if let outlineVersion {
|
||||
Text("Outline \(outlineVersion)")
|
||||
} else if !isEffectivelyOnline {
|
||||
Text("Outline — offline")
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func refreshOutlineVersion() async {
|
||||
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||
outlineVersion = try? await apiClient.installationInfo().version
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -41,6 +41,19 @@ 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?
|
||||
@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?
|
||||
@State private var isShowingApiKeyWebOnlyNotice = false
|
||||
|
||||
/// Full Local Sync and cache-clearing both need a real connection to be
|
||||
/// safe — clearing while offline (or letting Full Local Sync think it
|
||||
@@ -82,6 +95,8 @@ struct SettingsView: View {
|
||||
case .profile: profileDetail
|
||||
case .preferences: preferencesDetail
|
||||
case .notifications: notificationsDetail
|
||||
case .passkeys: passkeysDetail
|
||||
case .apiAccess: apiAccessDetail
|
||||
case .offlineSync: offlineSyncDetail
|
||||
case .advanced: advancedDetail
|
||||
case .about: aboutDetail
|
||||
@@ -89,9 +104,8 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything in Account/Workspace/Integrations & Installation that
|
||||
/// isn't `.profile` — real content lands section by section; this is
|
||||
/// just the nav skeleton until then.
|
||||
/// Everything in Account/Workspace that isn't `.profile` — real content
|
||||
/// lands section by section; this is just the nav skeleton until then.
|
||||
private var comingSoonDetail: some View {
|
||||
VStack(spacing: 16) {
|
||||
sectionHeader
|
||||
@@ -715,6 +729,337 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Passkeys
|
||||
|
||||
/// Read-only on purpose — Outline only exposes passkey management from
|
||||
/// its own web app (WebAuthn registration needs a browser context this
|
||||
/// native app doesn't have), so this page is informational, not a
|
||||
/// placeholder for missing functionality.
|
||||
private var passkeysDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
sectionHeader
|
||||
Text("Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
|
||||
if !isEffectivelyOnline {
|
||||
offlineSettingsHint
|
||||
}
|
||||
|
||||
Label("This setting can only be changed from the web version of Outline.", systemImage: "lock.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if !isEffectivelyOnline {
|
||||
offlineSettingsHint
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Personal keys")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
// TODO: apiKeys.create needs Outline's cookie+CSRF web
|
||||
// session, not this app's Bearer-token auth — confirmed
|
||||
// this app's requests to it don't work. Swap this back to
|
||||
// `isShowingCreateApiKey = true` (the real create sheet
|
||||
// below is fully built and untouched) once there's a
|
||||
// supported native auth path, or Outline adds Bearer
|
||||
// support for this endpoint.
|
||||
Button("New API Key…") { isShowingApiKeyWebOnlyNotice = true }
|
||||
}
|
||||
.frame(maxWidth: 480)
|
||||
|
||||
apiKeysList
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.opacity(isEffectivelyOnline ? 1 : 0.4)
|
||||
}
|
||||
.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.")
|
||||
}
|
||||
}
|
||||
.alert("Manage API Keys on the Web", isPresented: $isShowingApiKeyWebOnlyNotice) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text("Creating and deleting personal API keys is only supported on the web version of Outline. This app can display your existing keys, but not create or delete them.")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 || !isEffectivelyOnline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
@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 {
|
||||
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 {
|
||||
// TODO: apiKeys.delete — same web-session-only limitation
|
||||
// as create above, see that comment. Swap back to
|
||||
// `apiKeyPendingDeletion = key` (the real confirmation
|
||||
// dialog + deleteApiKey(_:) below are fully built and
|
||||
// untouched) once native auth can actually call it.
|
||||
Button {
|
||||
isShowingApiKeyWebOnlyNotice = true
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// TODO: not currently reachable from the UI — apiKeys.create needs
|
||||
// Outline's cookie+CSRF web session, confirmed this app's Bearer-token
|
||||
// requests to it don't work. Kept intact (and covered by OutlineKit
|
||||
// tests) for when native auth can support it; see the "New API Key…"
|
||||
// button's own TODO for the reconnect point.
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: not currently reachable from the UI — same apiKeys.delete
|
||||
// web-session-only limitation as createApiKey() above. Kept intact
|
||||
// for the same reason; see the trash button's own TODO.
|
||||
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
|
||||
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 {
|
||||
@@ -988,6 +1333,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)
|
||||
}
|
||||
@@ -1126,4 +1475,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
|
||||
|
||||
@@ -8,7 +8,6 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general
|
||||
case account
|
||||
case workspace
|
||||
case integrationsInstallation
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -17,19 +16,20 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case .general: return "Outpost"
|
||||
case .account: return "Account"
|
||||
case .workspace: return "Workspace"
|
||||
case .integrationsInstallation: return "Integrations & Installation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||
/// categories (Account/Workspace/Integrations & Installation) so this app's
|
||||
/// settings read as a native counterpart to the web app's, plus a `general`
|
||||
/// group for things that are ours and don't map onto Outline's structure
|
||||
/// (offline/sync, advanced, about, appearance).
|
||||
/// categories (Account/Workspace) so this app's settings read as a native
|
||||
/// counterpart to the web app's, plus a `general` group for things that are
|
||||
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||
/// about, appearance). Outline's own version info moved to the sidebar
|
||||
/// footer (`SettingsSidebarList`) instead of a standalone
|
||||
/// Integrations & Installation section.
|
||||
///
|
||||
/// Most of the Account/Workspace/Integrations cases are navigation-only for
|
||||
/// now — `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
@@ -41,9 +41,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// Workspace
|
||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||
|
||||
// Integrations & Installation
|
||||
case installation
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var category: SettingsCategory {
|
||||
@@ -54,8 +51,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
return .account
|
||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||
return .workspace
|
||||
case .installation:
|
||||
return .integrationsInstallation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +79,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
case .webhooks: return "Webhooks"
|
||||
case .importData: return "Import"
|
||||
case .exportData: return "Export"
|
||||
case .installation: return "Installation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,16 +107,15 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
case .webhooks: return "bolt.horizontal"
|
||||
case .importData: return "square.and.arrow.down"
|
||||
case .exportData: return "square.and.arrow.up"
|
||||
case .installation: return "shippingbox"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything actually built so far — everything else in Account/
|
||||
/// Workspace/Integrations & Installation renders a "Coming Soon"
|
||||
/// placeholder until its content is specified and built.
|
||||
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||
/// specified and built.
|
||||
var isImplemented: Bool {
|
||||
switch self {
|
||||
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications:
|
||||
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Single source of truth for how Outpost's own version is formatted —
|
||||
/// used by both the About page and the Settings sidebar footer, so they
|
||||
/// can't drift out of sync the way `AboutInfoView` was already written to
|
||||
/// avoid for its own two call sites.
|
||||
enum OutpostVersion {
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
static let releaseStage = "ALPHA"
|
||||
|
||||
static var shortVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
}
|
||||
|
||||
static var buildNumber: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
}
|
||||
|
||||
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
||||
static var displayString: String {
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "\(shortVersion)\(stageSuffix)"
|
||||
}
|
||||
|
||||
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
||||
static var fullVersionString: String {
|
||||
"Version \(displayString) (\(buildNumber))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user