feat(share): drop broken Published toggle, add real document permissions

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.
This commit is contained in:
2026-08-14 20:18:03 +01:00
parent 013df89b4f
commit 7216633299
11 changed files with 437 additions and 89 deletions
@@ -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
@@ -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))
}
@@ -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 `<resource>.<verb>` 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?
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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(
@@ -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)
@@ -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()
@@ -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.")
}
}
}