From 7216633299224b9219fc71892558fa22682135fc Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 20:18:03 +0100 Subject: [PATCH] feat(share): drop broken Published toggle, add real document permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flipping "Published" 403'd with authorization_error, confirmed via a raw curl (bypassing our client entirely, real token) to be a genuine server-side restriction independent of this app — workspace public sharing is enabled, token is full-scope, it's not document-specific. Root cause is most likely Outline gating that action behind an interactive session rather than API-token auth, but that's not definitively confirmed server-side. Removed the toggle per explicit instruction; kept link create/copy/revoke and title override (same endpoint, not reported broken). Replaced it with real per-document user permissions: - OutlineMembership/OutlineDocumentMember models - documents.add_user (confirmed shape from official docs), documents.remove_user/documents.users (speculative, same "best-effort until a live server confirms" treatment OutlinePin originally got), users.list for the invite search (standard, high-confidence) - DocumentShareSheet gets a "People with access" section: search and invite with a Can-view/Can-edit picker, existing members listed with a remove button - Reader toolbar's long-disabled "Permissions…" menu item now opens this same sheet instead of doing nothing Sidebar's own disabled "Permissions…" stub has no sheet wired up to it yet — comment updated to be accurate, not fixed (use the reader's menu instead). documents.users/documents.remove_user are unverified against a live server, same as every other speculative endpoint this session — expect a correction round once tested. --- .../OutlineKit/Core/OutlineAPIClient.swift | 8 + .../OutlineKit/LiveOutlineAPIClient.swift | 16 + .../OutlineKit/Models/OutlineMembership.swift | 31 ++ .../Requests/AddDocumentUserRequest.swift | 16 + .../Requests/ListDocumentUsersRequest.swift | 17 + .../Requests/ListUsersRequest.swift | 15 + .../Requests/RemoveDocumentUserRequest.swift | 14 + .../LiveOutlineAPIClientTests.swift | 83 +++++ .../CollectionDocumentsOutline.swift | 5 +- .../Collections/DocumentReaderView.swift | 10 +- .../Collections/DocumentShareSheet.swift | 311 +++++++++++++----- 11 files changed, 437 insertions(+), 89 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index cfdd47d..c1b0eac 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -48,6 +48,14 @@ public protocol OutlineAPIClient: Sendable { /// Historical view records, not live presence. Backed by `views.list`. func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] + /// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed + /// from Outline's official docs, the rest are best-effort. + func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership + func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws + func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] + /// For searching workspace members to invite. Backed by `users.list`. + func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] + func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] func collectionInfo(id: String) async throws -> OutlineCollection func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index a7f3029..13ca7e1 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -175,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("views.list", body: request) } + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { + try await post("documents.add_user", body: request) + } + + public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { + try await postForSuccess("documents.remove_user", body: request) + } + + public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { + try await post("documents.users", body: request) + } + + public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { + try await post("users.list", body: request) + } + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit)) } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift new file mode 100644 index 0000000..114efad --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift @@ -0,0 +1,31 @@ +import Foundation + +/// A user's explicit permission grant on a document, backed by +/// `documents.add_user`/`documents.remove_user`. Not in the vendored spec — +/// only `documents.add_user`'s shape is confirmed from Outline's official +/// docs; `remove` and the response shape here follow the create/delete and +/// `{data: ...}` conventions used throughout the rest of this API, but +/// aren't verified against a live server yet. +public struct OutlineMembership: Decodable, Identifiable, Sendable { + public let id: String + public let userId: String + public let documentId: String? + /// `"read"` or `"read_write"` per the documented enum. + public let permission: String +} + +/// A workspace member as returned by `documents.users` in the context of a +/// specific document — same identity fields as `OutlineUser`, plus (if the +/// server includes it) the permission they hold on that document. Modeled +/// separately from `OutlineUser` rather than adding an optional field there, +/// since `permission` only makes sense in this document-scoped context. +/// Speculative — `documents.users` isn't in the vendored spec, its name is +/// inferred from Outline's usual `.` convention and hasn't +/// been confirmed against a live server. +public struct OutlineDocumentMember: Decodable, Identifiable, Sendable { + public let id: String + public let name: String + public let email: String? + public let avatarUrl: String? + public let permission: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift new file mode 100644 index 0000000..d5d141c --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift @@ -0,0 +1,16 @@ +import Foundation + +/// See `OutlineMembership`. Confirmed shape from Outline's official +/// `documents.add_user` docs. +public struct AddDocumentUserRequest: Encodable, Sendable { + public let id: String + public let userId: String + /// `"read"` or `"read_write"`. + public let permission: String + + public init(id: String, userId: String, permission: String) { + self.id = id + self.userId = userId + self.permission = permission + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift new file mode 100644 index 0000000..9cd7870 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift @@ -0,0 +1,17 @@ +import Foundation + +/// See `OutlineDocumentMember`. Speculative — `documents.users` isn't in +/// the vendored spec. +public struct ListDocumentUsersRequest: Encodable, Sendable { + public let id: String + public let query: String? + public let offset: Int + public let limit: Int + + public init(id: String, query: String? = nil, offset: Int = 0, limit: Int = 25) { + self.id = id + self.query = query + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift new file mode 100644 index 0000000..7d3c17d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift @@ -0,0 +1,15 @@ +import Foundation + +/// For searching workspace members to invite to a document. Backed by +/// `users.list`. +public struct ListUsersRequest: Encodable, Sendable { + public let query: String? + public let offset: Int + public let limit: Int + + public init(query: String? = nil, offset: Int = 0, limit: Int = 25) { + self.query = query + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift new file mode 100644 index 0000000..7e609a1 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift @@ -0,0 +1,14 @@ +import Foundation + +/// See `OutlineMembership`. Speculative — follows the create/delete pairing +/// convention used elsewhere in this API (`documents.add_user` / +/// `documents.remove_user`), not confirmed against a live server yet. +public struct RemoveDocumentUserRequest: Encodable, Sendable { + public let id: String + public let userId: String + + public init(id: String, userId: String) { + self.id = id + self.userId = userId + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 998300d..39cb57d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -910,6 +910,89 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create") } + func testAddDocumentUserDecodesMembership() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let membership = try await client.addDocumentUser( + AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read") + ) + + XCTAssertEqual(membership.id, "mem-1") + XCTAssertEqual(membership.permission, "read") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user") + } + + func testRemoveDocumentUserSendsRequest() 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.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1")) + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user") + } + + func testDocumentUsersDecodesMembersArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1")) + + XCTAssertEqual(members.first?.name, "Jane Doe") + XCTAssertEqual(members.first?.permission, "read_write") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users") + } + + func testListUsersDecodesUsersArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let users = try await client.listUsers(ListUsersRequest(query: "Jane")) + + XCTAssertEqual(users.first?.name, "Jane Doe") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list") + } + func testMissingTokenThrowsTokenUnavailable() async throws { let httpClient = MockHTTPClient() let client = LiveOutlineAPIClient( diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 593fe2f..d627c0c 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -316,8 +316,9 @@ private struct DocumentNodeRow: View { renameText = node.document.title isShowingRenameAlert = true } - // Sharing/membership management is its own subsystem, not a one-off - // action — deferred rather than half-built here. + // DocumentShareSheet now has a real "People with access" section — + // this row just doesn't have a sheet wired up to present it yet + // (only the reader toolbar does). Use the reader's ⋯ menu instead. Button("Permissions…") {} .disabled(true) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 3ba40aa..e46b20c 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -299,10 +299,12 @@ struct DocumentReaderView: View { Button(viewModel.isEditing ? "Done Editing" : "Edit") { Task { await viewModel.toggleEditing() } } - // Sharing/membership management is its own subsystem, not a one-off - // action — deferred rather than half-built here. - Button("Permissions…") {} - .disabled(true) + // Membership management now lives in DocumentShareSheet's "People + // with access" section, alongside the share link — same sheet, + // same isShowingShareSheet state. + Button("Permissions…") { + isShowingShareSheet = true + } Divider() diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift index f4dc33f..261184b 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -12,13 +12,23 @@ struct DocumentShareSheet: View { @State private var share: OutlineShare? @State private var titleOverride = "" - @State private var isLoading = false - @State private var isUpdating = false + @State private var isLoadingShare = false + @State private var isUpdatingShare = false @State private var isRevoking = false @State private var isShowingRevokeConfirmation = false - @State private var errorMessage: String? + @State private var shareErrorMessage: String? @State private var didCopy = false + @State private var members: [OutlineDocumentMember] = [] + @State private var isLoadingMembers = false + @State private var isShowingAddPerson = false + @State private var userSearchQuery = "" + @State private var userSearchResults: [OutlineUser] = [] + @State private var isSearchingUsers = false + @State private var selectedPermission = "read" + @State private var isAddingUser = false + @State private var actionErrorMessage: String? + var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -28,67 +38,21 @@ struct DocumentShareSheet: View { Button("Done") { dismiss() } } - if isLoading { - ProgressView().frame(maxWidth: .infinity) - } else if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - } else if let share { - if let url = share.url { - HStack { - Text(url) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - copyLink(url) - } label: { - Image(systemName: didCopy ? "checkmark" : "doc.on.doc") - } - .buttonStyle(.plain) - } - .padding(8) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) - } + shareLinkSection - Toggle( - "Published — accessible without sign-in", - isOn: Binding( - get: { share.published }, - set: { newValue in Task { await setPublished(newValue) } } - ) - ) - .disabled(isUpdating) + Divider() - VStack(alignment: .leading, spacing: 4) { - Text("Public page title") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Uses the document's title if empty", text: $titleOverride) - .textFieldStyle(.roundedBorder) - .disabled(isUpdating) - .onSubmit { - Task { await setTitle(titleOverride) } - } - } - - Divider() - - Button("Revoke Link", role: .destructive) { - isShowingRevokeConfirmation = true - } - .disabled(isRevoking) - } else { - Button("Create Share Link") { - Task { await create() } - } - } + peopleSection } .padding(20) .frame(width: 380) - .task { await load() } + .task { await loadShare() } + .task { await loadMembers() } + .task(id: userSearchQuery) { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + await searchUsers(userSearchQuery) + } .confirmationDialog( "Revoke this share link?", isPresented: $isShowingRevokeConfirmation, @@ -101,6 +65,158 @@ struct DocumentShareSheet: View { } message: { Text("Anyone using this link will no longer be able to access the document.") } + .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { + Button("OK") { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } + } + + @ViewBuilder + private var shareLinkSection: some View { + if isLoadingShare { + ProgressView().frame(maxWidth: .infinity) + } else if let shareErrorMessage { + Text(shareErrorMessage) + .font(.callout) + .foregroundStyle(.red) + } else if let share { + if let url = share.url { + HStack { + Text(url) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + copyLink(url) + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.plain) + } + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Public page title") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Uses the document's title if empty", text: $titleOverride) + .textFieldStyle(.roundedBorder) + .disabled(isUpdatingShare) + .onSubmit { + Task { await setTitle(titleOverride) } + } + } + + Button("Revoke Link", role: .destructive) { + isShowingRevokeConfirmation = true + } + .disabled(isRevoking) + } else { + Button("Create Share Link") { + Task { await create() } + } + } + } + + @ViewBuilder + private var peopleSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("People with access") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + isShowingAddPerson.toggle() + } label: { + Image(systemName: "person.badge.plus") + } + .buttonStyle(.plain) + .help("Add a person") + } + + if isShowingAddPerson { + addPersonSection + } + + if isLoadingMembers { + ProgressView().controlSize(.small) + } else if members.isEmpty { + Text("No one else has explicit access yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(members) { member in + memberRow(member) + } + } + } + } + + private var addPersonSection: some View { + VStack(alignment: .leading, spacing: 6) { + TextField("Search people by name or email", text: $userSearchQuery) + .textFieldStyle(.roundedBorder) + + Picker("Permission", selection: $selectedPermission) { + Text("Can view").tag("read") + Text("Can edit").tag("read_write") + } + .pickerStyle(.segmented) + .labelsHidden() + + if isSearchingUsers { + ProgressView().controlSize(.small) + } else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if userSearchResults.isEmpty { + Text("No matches.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(userSearchResults) { user in + Button { + Task { await addUser(user) } + } label: { + HStack { + Text(user.name) + .font(.callout) + Spacer() + Image(systemName: "plus.circle") + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isAddingUser) + } + } + } + } + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + } + + private func memberRow(_ member: OutlineDocumentMember) -> some View { + HStack { + Text(member.name) + .font(.callout) + Spacer() + if let permission = member.permission { + Text(permission == "read_write" ? "Can edit" : "Can view") + .font(.caption) + .foregroundStyle(.secondary) + } + Button { + Task { await removeUser(member) } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } } private func copyLink(_ url: String) { @@ -114,38 +230,25 @@ struct DocumentShareSheet: View { } } - private func load() async { - isLoading = true - defer { isLoading = false } + private func loadShare() async { + isLoadingShare = true + defer { isLoadingShare = false } do { share = try await apiClient.shareInfo(documentId: documentId) titleOverride = share?.title ?? "" } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") + shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") } } private func create() async { - isLoading = true - defer { isLoading = false } + isLoadingShare = true + defer { isLoadingShare = false } do { share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) titleOverride = share?.title ?? "" } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") - } - } - - private func setPublished(_ published: Bool) async { - guard let share else { return } - isUpdating = true - defer { isUpdating = false } - do { - self.share = try await apiClient.updateShare( - UpdateShareRequest(id: share.id, published: published, title: share.title) - ) - } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") } } @@ -153,14 +256,14 @@ struct DocumentShareSheet: View { guard let share else { return } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed != (share.title ?? "") else { return } - isUpdating = true - defer { isUpdating = false } + isUpdatingShare = true + defer { isUpdatingShare = false } do { self.share = try await apiClient.updateShare( UpdateShareRequest(id: share.id, published: share.published, title: trimmed) ) } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") } } @@ -173,7 +276,49 @@ struct DocumentShareSheet: View { self.share = nil titleOverride = "" } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") + } + } + + private func loadMembers() async { + isLoadingMembers = true + defer { isLoadingMembers = false } + members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? [] + } + + private func searchUsers(_ query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + userSearchResults = [] + return + } + isSearchingUsers = true + defer { isSearchingUsers = false } + userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? [] + } + + private func addUser(_ user: OutlineUser) async { + isAddingUser = true + defer { isAddingUser = false } + do { + _ = try await apiClient.addDocumentUser( + AddDocumentUserRequest(id: documentId, userId: user.id, permission: selectedPermission) + ) + userSearchQuery = "" + userSearchResults = [] + isShowingAddPerson = false + await loadMembers() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add this person.") + } + } + + private func removeUser(_ member: OutlineDocumentMember) async { + do { + try await apiClient.removeDocumentUser(RemoveDocumentUserRequest(id: documentId, userId: member.id)) + await loadMembers() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this person.") } } }