diff --git a/InterlinedList/Services/DocumentInviteTarget.cs b/InterlinedList/Services/DocumentInviteTarget.cs
new file mode 100644
index 0000000..7f009cd
--- /dev/null
+++ b/InterlinedList/Services/DocumentInviteTarget.cs
@@ -0,0 +1,38 @@
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// Binds the shared invite panel to one document's
+/// /api/documents/{id}/invites routes. Mirror image of
+/// — the sharing model is identical, so the panel
+/// itself needs no document-specific branch.
+///
+public sealed class DocumentInviteTarget : IInviteTarget
+{
+ private readonly InterlinedApiClient _api;
+ private readonly string _documentId;
+
+ public DocumentInviteTarget(InterlinedApiClient api, string documentId)
+ {
+ _api = api;
+ _documentId = documentId;
+ }
+
+ public string ResourceNoun => "document";
+
+ public Task> GetInvitesAsync(CancellationToken ct = default)
+ => _api.GetDocumentInvitesAsync(_documentId, ct);
+
+ public Task CreateInviteAsync(
+ string email, string role, DateTimeOffset? expiresAt, CancellationToken ct = default)
+ => _api.CreateDocumentInviteAsync(_documentId, email, role, expiresAt, ct);
+
+ public Task RevokeInviteAsync(string token, CancellationToken ct = default)
+ => _api.DeleteDocumentInviteAsync(_documentId, token, ct);
+
+ public Task GetOwnerUserIdAsync(CancellationToken ct = default)
+ => _api.GetDocumentOwnerUserIdAsync(_documentId, ct);
+
+ public string InviteUrlFor(string token) => $"{ApiConfig.BaseUrl}documents/invite/{token}";
+}
diff --git a/InterlinedList/Services/IInviteTarget.cs b/InterlinedList/Services/IInviteTarget.cs
index 6c8c52a..e9c312f 100644
--- a/InterlinedList/Services/IInviteTarget.cs
+++ b/InterlinedList/Services/IInviteTarget.cs
@@ -57,8 +57,7 @@ public static class InviteTargets
return kind switch
{
InviteTargetKind.List => new ListInviteTarget(api, resourceId),
- // Document invites land with issue #56 (same endpoints under
- // /api/documents) and slot in here.
+ InviteTargetKind.Document => new DocumentInviteTarget(api, resourceId),
_ => null,
};
}
diff --git a/InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs b/InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs
new file mode 100644
index 0000000..02d6d96
--- /dev/null
+++ b/InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs
@@ -0,0 +1,84 @@
+using System.Net.Http;
+using System.Text.Json;
+using InterlinedList.Models;
+
+namespace InterlinedList.Services;
+
+///
+/// Email invites to a document — the same sharing model as
+/// InterlinedApiClient.ListInvites.cs, and the path the web Share window leads
+/// with. Distinct from the share links and collaborators in
+/// InterlinedApiClient.Documents.cs: a share link is a bearer capability and a
+/// collaborator is an existing account, whereas an invite is bound to an email
+/// address that may not have an account yet.
+///
+/// All three routes are owner-only (a non-owner gets 404 — existence is never
+/// leaked). Creating is subscriber-gated (403 for a free owner); listing and
+/// revoking are not, so an owner whose subscription lapsed can always shut off
+/// access they previously granted.
+///
+/// Verified live 2026-09-16 against a throwaway document, and the payloads are
+/// byte-identical to the list ones — hence the shared
+/// wire type:
+///
+/// GET /api/documents/{id}/invites → 200 { "invites": [ … ] }
+/// POST /api/documents/{id}/invites → 201 { email, role, expiresAt, url }
+/// DELETE /api/documents/{id}/invites/{token} → 200 { "revoked": true }
+///
+///
+public sealed partial class InterlinedApiClient
+{
+ public async Task> GetDocumentInvitesAsync(string documentId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/documents/{documentId}/invites", ct);
+ return json.TryGetProperty("invites", out var arr) && arr.ValueKind == JsonValueKind.Array
+ ? arr.Deserialize>(JsonOptions) ?? new()
+ : new();
+ }
+
+ ///
+ /// Invite an email address to this document. Re-inviting the same address is
+ /// idempotent server-side: it re-issues a fresh token and resets the invite
+ /// to unclaimed. An invite email carrying the returned url is sent to the
+ /// address best-effort (fire-and-forget), so this is a real outbound email —
+ /// callers should not exercise it against addresses they don't own.
+ ///
+ /// The returned envelope IS live-verified, but it carries no token, so
+ /// callers still re-read afterwards to
+ /// get the token they need for .
+ ///
+ public Task CreateDocumentInviteAsync(
+ string documentId,
+ string email,
+ string role = ShareRoles.Viewer,
+ DateTimeOffset? expiresAt = null,
+ CancellationToken ct = default)
+ => SendJsonAsync(HttpMethod.Post, $"api/documents/{documentId}/invites",
+ new { email, role, expiresAt = expiresAt?.UtcDateTime }, ct);
+
+ public Task DeleteDocumentInviteAsync(string documentId, string token, CancellationToken ct = default)
+ => SendVoidAsync(HttpMethod.Delete,
+ $"api/documents/{documentId}/invites/{Uri.EscapeDataString(token)}", null, ct);
+
+ ///
+ /// The document owner's user id, read straight off the GET
+ /// /api/documents/{id} "document" envelope (verified live 2026-09-16 — the
+ /// payload carries userId, which does not
+ /// model). Only the true owner can manage sharing — not even a manager-role
+ /// collaborator can — so the share UI compares this against the signed-in
+ /// user rather than assuming the open document is owned. Returns null when
+ /// the field is missing.
+ ///
+ /// Note the envelope key differs from the list equivalent: documents nest
+ /// under "document", lists under "data".
+ ///
+ public async Task GetDocumentOwnerUserIdAsync(string documentId, CancellationToken ct = default)
+ {
+ var json = await GetElementAsync($"api/documents/{documentId}", ct);
+ return json.TryGetProperty("document", out var doc)
+ && doc.TryGetProperty("userId", out var userId)
+ && userId.ValueKind == JsonValueKind.String
+ ? userId.GetString()
+ : null;
+ }
+}
diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml
index 7362f87..7f8347d 100644
--- a/InterlinedList/Views/DocumentsView.xaml
+++ b/InterlinedList/Views/DocumentsView.xaml
@@ -434,6 +434,10 @@
Margin="20,0,20,20"
Padding="16,12">
+
+
+