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
54 changes: 54 additions & 0 deletions InterlinedList/Models/EmailInvite.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace InterlinedList.Models;

/// <summary>
/// An email invite to a private list or document. Unlike a <see cref="ShareLink"/>
/// (a bearer capability — whoever holds the token has access) an invite is bound
/// to the invited address: the token only becomes access once a signed-in user
/// with that verified email claims it, so a forwarded invite link is useless to
/// anyone else. The invited person may not have an account yet.
///
/// One wire shape serves lists and documents. Verified live 2026-09-16:
/// <code>
/// GET /api/{lists|documents}/{id}/invites
/// → { "invites": [ { email, role, expiresAt, accepted, createdAt, token } ] }
/// POST /api/{lists|documents}/{id}/invites { email, role, expiresAt }
/// → 201 { email, role, expiresAt, url }
/// </code>
/// Note the asymmetry: <see cref="Token"/> comes back only from the GET and
/// <see cref="Url"/> only from the POST, so both are nullable. Revoking needs
/// the token, which is why callers refresh from the GET after creating one.
/// </summary>
public sealed class EmailInvite
{
public required string Email { get; init; }

/// <summary>A <see cref="ShareRoles"/> wire value; absent means Read-only.</summary>
public string? Role { get; init; }

public DateTimeOffset? ExpiresAt { get; init; }

/// <summary>Flips to true once a signed-in user with this email has claimed it.</summary>
public bool Accepted { get; init; }

public DateTimeOffset? CreatedAt { get; init; }

/// <summary>The revoke key. Present on the GET listing, absent from the POST response.</summary>
public string? Token { get; init; }

/// <summary>The invite landing address. Present on the POST response, absent from the GET listing.</summary>
public string? Url { get; init; }

public string RoleLabel => ShareRoles.LabelFor(Role);

public string StatusLabel => Accepted ? "Accepted" : "Pending";

public string ExpiryLabel => ExpiresAt is null
? "no expiry"
: $"expires {ExpiresAt.Value.ToLocalTime():d}";

/// <summary>Role · status · expiry, as the pending-invites row shows it.</summary>
public string MetaLine => $"{RoleLabel} · {StatusLabel} · {ExpiryLabel}";

/// <summary>False for the envelope returned by a create call, which carries no token.</summary>
public bool CanRevoke => !string.IsNullOrEmpty(Token);
}
41 changes: 41 additions & 0 deletions InterlinedList/Models/ShareRoles.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace InterlinedList.Models;

/// <summary>
/// The three sharing roles the server accepts for every per-person grant,
/// share link and email invite on both lists and documents. The wire values
/// and their UI labels are fixed by the product (see /help/api/sharing and
/// /help/documents): sending anything else returns
/// <c>400 "Invalid role. Must be watcher, collaborator, or manager"</c>
/// (verified live 2026-09-16).
/// </summary>
public static class ShareRoles
{
/// <summary>Read-only view of the resource. The server default when role is omitted.</summary>
public const string Viewer = "watcher";

/// <summary>View and edit content only — cannot rename, change visibility, or move.</summary>
public const string Editor = "collaborator";

/// <summary>Everything Editor can, plus rename, visibility, move and delete.</summary>
public const string Admin = "manager";

/// <summary>The label the web UI shows for a wire role value.</summary>
public static string LabelFor(string? role) => role switch
{
Editor => "Edit",
Admin => "Admin",
Viewer or null or "" => "Read-only",
// Forward-compatible: show an unrecognised server role verbatim rather
// than mislabelling it as Read-only.
_ => role,
};

/// <summary>One-line explanation of what a role can do, for the invite picker.</summary>
public static string DescriptionFor(string? role) => role switch
{
Editor => "Can change content, but not rename, move, or make public",
Admin => "Can also rename, move, change visibility, and delete",
Viewer or null or "" => "Can view the content only",
_ => string.Empty,
};
}
65 changes: 65 additions & 0 deletions InterlinedList/Services/IInviteTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// The kind of resource an <see cref="IInviteTarget"/> addresses. Lists and
/// documents use one identical sharing model, so one invite panel drives both.
/// </summary>
public enum InviteTargetKind
{
List,
Document,
}

/// <summary>
/// One resource's email-invite endpoints, seen from the UI. Lists and documents
/// have byte-identical invite payloads and differ only in the route segment and
/// the ownership envelope, so <c>InvitePanel</c> binds to this instead of
/// knowing about either domain.
/// </summary>
public interface IInviteTarget
{
/// <summary>"list" or "document" — used in the panel's prose.</summary>
string ResourceNoun { get; }

Task<List<EmailInvite>> GetInvitesAsync(CancellationToken ct = default);

Task<EmailInvite> CreateInviteAsync(
string email, string role, DateTimeOffset? expiresAt, CancellationToken ct = default);

Task RevokeInviteAsync(string token, CancellationToken ct = default);

/// <summary>
/// The resource owner's user id, or null when the server didn't report one.
/// Only the true owner may manage sharing, so the panel gates on this.
/// </summary>
Task<string?> GetOwnerUserIdAsync(CancellationToken ct = default);

/// <summary>
/// The invite landing address for a token. The create response returns this
/// as <c>url</c>; the GET listing doesn't, so it's rebuilt from the token
/// (format verified live 2026-09-16) to offer Copy link on every row.
/// </summary>
string InviteUrlFor(string token);
}

/// <summary>
/// Builds the <see cref="IInviteTarget"/> for a selected resource so the view
/// layer stays free of per-domain wiring.
/// </summary>
public static class InviteTargets
{
public static IInviteTarget? For(InviteTargetKind kind, string? resourceId, InterlinedApiClient api)
{
if (string.IsNullOrEmpty(resourceId)) return null;

return kind switch
{
InviteTargetKind.List => new ListInviteTarget(api, resourceId),
// Document invites land with issue #56 (same endpoints under
// /api/documents) and slot in here.
_ => null,
};
}
}
80 changes: 80 additions & 0 deletions InterlinedList/Services/InterlinedApiClient.ListInvites.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Net.Http;
using System.Text.Json;
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// Email invites to a list — the "invite people, keep it private" path the web
/// Share window leads with. Distinct from the share links and watchers in
/// InterlinedApiClient.Lists.cs: a share link is a bearer capability and a
/// watcher 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 list:
/// <code>
/// GET /api/lists/{id}/invites → 200 { "invites": [ … ] }
/// POST /api/lists/{id}/invites → 201 { email, role, expiresAt, url }
/// DELETE /api/lists/{id}/invites/{token} → 200 { "revoked": true }
/// DELETE …/invites/{unknown token} → 404 { error: "Invite not found or access denied" }
/// POST … { email: "not-an-email" } → 400 { error: "A valid email address is required" }
/// POST … { role: "bogus" } → 400 { error: "Invalid role. Must be watcher, collaborator, or manager" }
/// </code>
/// </summary>
public sealed partial class InterlinedApiClient
{
public async Task<List<EmailInvite>> GetListInvitesAsync(string listId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/lists/{listId}/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 list. 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="GetListInvitesAsync"/> afterwards to get
/// the token they need for <see cref="DeleteListInviteAsync"/>.
/// </summary>
public Task<EmailInvite> CreateListInviteAsync(
string listId,
string email,
string role = ShareRoles.Viewer,
DateTimeOffset? expiresAt = null,
CancellationToken ct = default)
=> SendJsonAsync<EmailInvite>(HttpMethod.Post, $"api/lists/{listId}/invites",
new { email, role, expiresAt = expiresAt?.UtcDateTime }, ct);

public Task DeleteListInviteAsync(string listId, string token, CancellationToken ct = default)
=> SendVoidAsync(HttpMethod.Delete,
$"api/lists/{listId}/invites/{Uri.EscapeDataString(token)}", null, ct);

/// <summary>
/// The list owner's user id, read straight off the GET /api/lists/{id}
/// "data" envelope (verified live 2026-09-16 — the payload carries userId,
/// which <see cref="ListSummary"/> 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
/// selected list is owned. Returns null when the field is missing.
/// </summary>
public async Task<string?> GetListOwnerUserIdAsync(string listId, CancellationToken ct = default)
{
var json = await GetElementAsync($"api/lists/{listId}", ct);
return json.TryGetProperty("data", out var data)
&& data.TryGetProperty("userId", out var userId)
&& userId.ValueKind == JsonValueKind.String
? userId.GetString()
: null;
}
}
35 changes: 35 additions & 0 deletions InterlinedList/Services/ListInviteTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// Binds the shared invite panel to one list's /api/lists/{id}/invites routes.
/// </summary>
public sealed class ListInviteTarget : IInviteTarget
{
private readonly InterlinedApiClient _api;
private readonly string _listId;

public ListInviteTarget(InterlinedApiClient api, string listId)
{
_api = api;
_listId = listId;
}

public string ResourceNoun => "list";

public Task<List<EmailInvite>> GetInvitesAsync(CancellationToken ct = default)
=> _api.GetListInvitesAsync(_listId, ct);

public Task<EmailInvite> CreateInviteAsync(
string email, string role, DateTimeOffset? expiresAt, CancellationToken ct = default)
=> _api.CreateListInviteAsync(_listId, email, role, expiresAt, ct);

public Task RevokeInviteAsync(string token, CancellationToken ct = default)
=> _api.DeleteListInviteAsync(_listId, token, ct);

public Task<string?> GetOwnerUserIdAsync(CancellationToken ct = default)
=> _api.GetListOwnerUserIdAsync(_listId, ct);

public string InviteUrlFor(string token) => $"{ApiConfig.BaseUrl}lists/invite/{token}";
}
30 changes: 30 additions & 0 deletions InterlinedList/ViewModels/InviteOptionViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using CommunityToolkit.Mvvm.ComponentModel;

namespace InterlinedList.ViewModels;

/// <summary>
/// One choice in the invite panel's role or expiry picker. The app has no
/// ComboBox anywhere (native combo chrome doesn't follow the Strata themes), so
/// both pickers render as a row of small selectable buttons driven by
/// <see cref="IsSelected"/> rather than by RadioButton grouping — grouping is
/// visual-tree scoped and would misbehave with two panels alive in the
/// MainWindow view cache at once.
/// </summary>
public partial class InviteOptionViewModel : ObservableObject
{
public required string Label { get; init; }

/// <summary>Secondary line explaining the choice (role pickers only).</summary>
public string? Detail { get; init; }

/// <summary>A <see cref="Models.ShareRoles"/> wire value, for role options.</summary>
public string? Role { get; init; }

/// <summary>How long the invite stays valid; null means no expiry.</summary>
public TimeSpan? Duration { get; init; }

[ObservableProperty]
private bool isSelected;

public bool HasDetail => !string.IsNullOrEmpty(Detail);
}
Loading
Loading