Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions InterlinedList/Services/DocumentInviteTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// Binds the shared invite panel to one document's
/// /api/documents/{id}/invites routes. Mirror image of
/// <see cref="ListInviteTarget"/> — the sharing model is identical, so the panel
/// itself needs no document-specific branch.
/// </summary>
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<List<EmailInvite>> GetInvitesAsync(CancellationToken ct = default)
=> _api.GetDocumentInvitesAsync(_documentId, ct);

public Task<EmailInvite> 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<string?> GetOwnerUserIdAsync(CancellationToken ct = default)
=> _api.GetDocumentOwnerUserIdAsync(_documentId, ct);

public string InviteUrlFor(string token) => $"{ApiConfig.BaseUrl}documents/invite/{token}";
}
3 changes: 1 addition & 2 deletions InterlinedList/Services/IInviteTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
84 changes: 84 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.Net.Http;
using System.Text.Json;
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// 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 <see cref="EmailInvite"/>
/// wire type:
/// <code>
/// 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 }
/// </code>
/// </summary>
public sealed partial class InterlinedApiClient
{
public async Task<List<EmailInvite>> 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<List<EmailInvite>>(JsonOptions) ?? new()
: new();
}

/// <summary>
/// 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 <see cref="GetDocumentInvitesAsync"/> afterwards to
/// get the token they need for <see cref="DeleteDocumentInviteAsync"/>.
/// </summary>
public Task<EmailInvite> CreateDocumentInviteAsync(
string documentId,
string email,
string role = ShareRoles.Viewer,
DateTimeOffset? expiresAt = null,
CancellationToken ct = default)
=> SendJsonAsync<EmailInvite>(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);

/// <summary>
/// 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 <see cref="DocumentSummary"/> 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".
/// </summary>
public async Task<string?> 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;
}
}
4 changes: 4 additions & 0 deletions InterlinedList/Views/DocumentsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@
Margin="20,0,20,20"
Padding="16,12">
<StackPanel>
<!-- Invite people by email — leads the Share card, as the web does
(inviting keeps the document private; links are secondary). -->
<local:InvitePanel Kind="Document" TargetId="{Binding SelectedDocument.Id}" Margin="0,0,0,12"/>

<DockPanel Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left"
Text="Share"
Expand Down
Loading