diff --git a/InterlinedList/Models/EmailInvite.cs b/InterlinedList/Models/EmailInvite.cs new file mode 100644 index 0000000..8969f94 --- /dev/null +++ b/InterlinedList/Models/EmailInvite.cs @@ -0,0 +1,54 @@ +namespace InterlinedList.Models; + +/// +/// An email invite to a private list or document. Unlike a +/// (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: +/// +/// 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 } +/// +/// Note the asymmetry: comes back only from the GET and +/// only from the POST, so both are nullable. Revoking needs +/// the token, which is why callers refresh from the GET after creating one. +/// +public sealed class EmailInvite +{ + public required string Email { get; init; } + + /// A wire value; absent means Read-only. + public string? Role { get; init; } + + public DateTimeOffset? ExpiresAt { get; init; } + + /// Flips to true once a signed-in user with this email has claimed it. + public bool Accepted { get; init; } + + public DateTimeOffset? CreatedAt { get; init; } + + /// The revoke key. Present on the GET listing, absent from the POST response. + public string? Token { get; init; } + + /// The invite landing address. Present on the POST response, absent from the GET listing. + 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}"; + + /// Role · status · expiry, as the pending-invites row shows it. + public string MetaLine => $"{RoleLabel} · {StatusLabel} · {ExpiryLabel}"; + + /// False for the envelope returned by a create call, which carries no token. + public bool CanRevoke => !string.IsNullOrEmpty(Token); +} diff --git a/InterlinedList/Models/ShareRoles.cs b/InterlinedList/Models/ShareRoles.cs new file mode 100644 index 0000000..028b8fc --- /dev/null +++ b/InterlinedList/Models/ShareRoles.cs @@ -0,0 +1,41 @@ +namespace InterlinedList.Models; + +/// +/// 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 +/// 400 "Invalid role. Must be watcher, collaborator, or manager" +/// (verified live 2026-09-16). +/// +public static class ShareRoles +{ + /// Read-only view of the resource. The server default when role is omitted. + public const string Viewer = "watcher"; + + /// View and edit content only — cannot rename, change visibility, or move. + public const string Editor = "collaborator"; + + /// Everything Editor can, plus rename, visibility, move and delete. + public const string Admin = "manager"; + + /// The label the web UI shows for a wire role value. + 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, + }; + + /// One-line explanation of what a role can do, for the invite picker. + 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, + }; +} diff --git a/InterlinedList/Services/IInviteTarget.cs b/InterlinedList/Services/IInviteTarget.cs new file mode 100644 index 0000000..6c8c52a --- /dev/null +++ b/InterlinedList/Services/IInviteTarget.cs @@ -0,0 +1,65 @@ +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// The kind of resource an addresses. Lists and +/// documents use one identical sharing model, so one invite panel drives both. +/// +public enum InviteTargetKind +{ + List, + Document, +} + +/// +/// 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 InvitePanel binds to this instead of +/// knowing about either domain. +/// +public interface IInviteTarget +{ + /// "list" or "document" — used in the panel's prose. + string ResourceNoun { get; } + + Task> GetInvitesAsync(CancellationToken ct = default); + + Task CreateInviteAsync( + string email, string role, DateTimeOffset? expiresAt, CancellationToken ct = default); + + Task RevokeInviteAsync(string token, CancellationToken ct = default); + + /// + /// 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. + /// + Task GetOwnerUserIdAsync(CancellationToken ct = default); + + /// + /// The invite landing address for a token. The create response returns this + /// as url; 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. + /// + string InviteUrlFor(string token); +} + +/// +/// Builds the for a selected resource so the view +/// layer stays free of per-domain wiring. +/// +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, + }; + } +} diff --git a/InterlinedList/Services/InterlinedApiClient.ListInvites.cs b/InterlinedList/Services/InterlinedApiClient.ListInvites.cs new file mode 100644 index 0000000..d7b5a69 --- /dev/null +++ b/InterlinedList/Services/InterlinedApiClient.ListInvites.cs @@ -0,0 +1,80 @@ +using System.Net.Http; +using System.Text.Json; +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// 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: +/// +/// 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" } +/// +/// +public sealed partial class InterlinedApiClient +{ + public async Task> 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>(JsonOptions) ?? new() + : new(); + } + + /// + /// 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 afterwards to get + /// the token they need for . + /// + public Task CreateListInviteAsync( + string listId, + string email, + string role = ShareRoles.Viewer, + DateTimeOffset? expiresAt = null, + CancellationToken ct = default) + => SendJsonAsync(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); + + /// + /// 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 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. + /// + public async Task 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; + } +} diff --git a/InterlinedList/Services/ListInviteTarget.cs b/InterlinedList/Services/ListInviteTarget.cs new file mode 100644 index 0000000..cb989c7 --- /dev/null +++ b/InterlinedList/Services/ListInviteTarget.cs @@ -0,0 +1,35 @@ +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// Binds the shared invite panel to one list's /api/lists/{id}/invites routes. +/// +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> GetInvitesAsync(CancellationToken ct = default) + => _api.GetListInvitesAsync(_listId, ct); + + public Task 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 GetOwnerUserIdAsync(CancellationToken ct = default) + => _api.GetListOwnerUserIdAsync(_listId, ct); + + public string InviteUrlFor(string token) => $"{ApiConfig.BaseUrl}lists/invite/{token}"; +} diff --git a/InterlinedList/ViewModels/InviteOptionViewModel.cs b/InterlinedList/ViewModels/InviteOptionViewModel.cs new file mode 100644 index 0000000..a78b22b --- /dev/null +++ b/InterlinedList/ViewModels/InviteOptionViewModel.cs @@ -0,0 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace InterlinedList.ViewModels; + +/// +/// 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 +/// rather than by RadioButton grouping — grouping is +/// visual-tree scoped and would misbehave with two panels alive in the +/// MainWindow view cache at once. +/// +public partial class InviteOptionViewModel : ObservableObject +{ + public required string Label { get; init; } + + /// Secondary line explaining the choice (role pickers only). + public string? Detail { get; init; } + + /// A wire value, for role options. + public string? Role { get; init; } + + /// How long the invite stays valid; null means no expiry. + public TimeSpan? Duration { get; init; } + + [ObservableProperty] + private bool isSelected; + + public bool HasDetail => !string.IsNullOrEmpty(Detail); +} diff --git a/InterlinedList/ViewModels/InvitePanelViewModel.cs b/InterlinedList/ViewModels/InvitePanelViewModel.cs new file mode 100644 index 0000000..98be89d --- /dev/null +++ b/InterlinedList/ViewModels/InvitePanelViewModel.cs @@ -0,0 +1,319 @@ +using System.Collections.ObjectModel; +using System.Net.Mail; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// Drives Views/InvitePanel — the "Invite people" card the web Share +/// window leads with. Domain-agnostic: it talks to an +/// so the same panel serves lists (issue #55) and documents (issue #56). +/// +/// Three product rules from /help/documents and /help/api/sharing are encoded +/// here rather than left to the server's error message: +/// +/// Only the true owner may add, re-role or remove people — not even +/// a manager-role collaborator can — so everything is gated on +/// . +/// Creating an invite is subscriber-only, but the controls stay +/// visible and locked with an Upgrade prompt rather than disappearing, and the +/// invitee never pays. +/// Revoking is deliberately not subscriber-gated, so a lapsed owner +/// can still shut off access they granted — Revoke stays live even while the +/// send form is locked. +/// +/// +public partial class InvitePanelViewModel : ObservableObject +{ + private readonly SessionService _session; + private IInviteTarget? _target; + + /// Guards against a stale in-flight load painting over a newer selection. + private int _loadGeneration; + + public ObservableCollection Invites { get; } = new(); + public ObservableCollection Roles { get; } = new(); + public ObservableCollection Expiries { get; } = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SendInviteCommand))] + private string inviteEmail = ""; + + [ObservableProperty] + private bool isLoading; + + [ObservableProperty] + private string? errorMessage; + + [ObservableProperty] + private string? statusMessage; + + /// True once a list/document is selected; the whole card hides otherwise. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUpgradePrompt), nameof(ShowNotOwnerNotice))] + [NotifyCanExecuteChangedFor(nameof(SendInviteCommand))] + private bool hasTarget; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanManage), nameof(ShowUpgradePrompt), nameof(ShowNotOwnerNotice))] + [NotifyCanExecuteChangedFor(nameof(SendInviteCommand))] + private bool isOwner; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanManage), nameof(ShowUpgradePrompt))] + [NotifyCanExecuteChangedFor(nameof(SendInviteCommand))] + private bool isSubscriber; + + /// "list" / "document" — folded into the card's prose. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PrivacyNote), nameof(UpgradeNote), nameof(NotOwnerNote), nameof(EmptyNote))] + private string resourceNoun = "item"; + + /// Send is owner + subscriber; Revoke only needs owner. + public bool CanManage => IsOwner && IsSubscriber; + + public bool ShowUpgradePrompt => HasTarget && IsOwner && !IsSubscriber; + + public bool ShowNotOwnerNotice => HasTarget && !IsOwner; + + public string PrivacyNote => + $"Invited people get access while this {ResourceNoun} stays private. " + + "They don't need an account yet, and they never pay for the access you grant."; + + public string UpgradeNote => + $"Inviting people to a {ResourceNoun} is a subscriber feature. Upgrade on the web to send invites — " + + "anyone you invite still gets their access for free. You can always revoke an invite you already sent."; + + public string NotOwnerNote => + $"Only the owner of this {ResourceNoun} can invite people or change roles."; + + public string EmptyNote => $"No invites yet. Invite someone by email to share this {ResourceNoun} privately."; + + public InvitePanelViewModel(SessionService session) + { + _session = session; + + Roles.Add(new InviteOptionViewModel + { + Label = "Read-only", + Detail = ShareRoles.DescriptionFor(ShareRoles.Viewer), + Role = ShareRoles.Viewer, + IsSelected = true, + }); + Roles.Add(new InviteOptionViewModel + { + Label = "Edit", + Detail = ShareRoles.DescriptionFor(ShareRoles.Editor), + Role = ShareRoles.Editor, + }); + Roles.Add(new InviteOptionViewModel + { + Label = "Admin", + Detail = ShareRoles.DescriptionFor(ShareRoles.Admin), + Role = ShareRoles.Admin, + }); + + // Matches the web's expiry choices for a share grant. + Expiries.Add(new InviteOptionViewModel { Label = "Never", Duration = null, IsSelected = true }); + Expiries.Add(new InviteOptionViewModel { Label = "7 days", Duration = TimeSpan.FromDays(7) }); + Expiries.Add(new InviteOptionViewModel { Label = "30 days", Duration = TimeSpan.FromDays(30) }); + + RefreshSubscriberState(); + _session.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(SessionService.CurrentUser)) + RefreshSubscriberState(); + }; + } + + /// + /// Point the panel at a resource (or at nothing). Called by the hosting + /// view whenever its selection changes. + /// + public async Task SetTargetAsync(IInviteTarget? target) + { + _target = target; + var generation = ++_loadGeneration; + + Invites.Clear(); + ErrorMessage = null; + StatusMessage = null; + InviteEmail = ""; + IsOwner = false; + HasTarget = target is not null; + ResourceNoun = target?.ResourceNoun ?? "item"; + RefreshSubscriberState(); + + if (target is null) return; + + IsLoading = true; + try + { + var ownerId = await target.GetOwnerUserIdAsync(); + if (generation != _loadGeneration) return; + + // No owner id reported → don't unlock sharing on a guess; the server + // is the real gate and answers 404 for a non-owner anyway. + IsOwner = ownerId is { Length: > 0 } + && string.Equals(ownerId, _session.CurrentUser?.Id, StringComparison.Ordinal); + + if (!IsOwner) return; + + await ReloadInvitesAsync(generation); + } + catch (InterlinedApiException ex) + { + // 404 here is the documented "you are not the owner" answer — the + // server never leaks whether the resource exists. + if (generation == _loadGeneration && ex.StatusCode != 404) + ErrorMessage = ex.Message; + } + finally + { + if (generation == _loadGeneration) IsLoading = false; + } + } + + [RelayCommand] + private Task RefreshAsync() => ReloadInvitesAsync(_loadGeneration); + + private bool CanSendInvite() => + HasTarget && CanManage && IsValidEmail(InviteEmail); + + [RelayCommand(CanExecute = nameof(CanSendInvite))] + private async Task SendInviteAsync() + { + if (_target is not { } target) return; + + var role = Roles.FirstOrDefault(r => r.IsSelected)?.Role ?? ShareRoles.Viewer; + var duration = Expiries.FirstOrDefault(e => e.IsSelected)?.Duration; + var expiresAt = duration is { } span ? DateTimeOffset.UtcNow.Add(span) : (DateTimeOffset?)null; + var email = InviteEmail.Trim(); + + ErrorMessage = null; + StatusMessage = null; + IsLoading = true; + try + { + var created = await target.CreateInviteAsync(email, role, expiresAt); + InviteEmail = ""; + StatusMessage = $"Invite emailed to {created.Email} as {created.RoleLabel}."; + + // Read-after-write: the create response carries no token, and the + // token is what Revoke needs. + await ReloadInvitesAsync(_loadGeneration); + } + catch (InterlinedApiException ex) + { + // 403 is the subscriber gate; it runs before any resource lookup so + // existence never leaks to a free account. + ErrorMessage = ex.StatusCode == 403 + ? $"{ex.Message} Inviting requires a subscription — the person you invite still gets access free." + : ex.Message; + } + finally + { + IsLoading = false; + } + } + + /// + /// Revoke stays available to a lapsed subscriber, by design — the guard is + /// ownership plus a token, not a subscription. (Kept as an in-body check + /// rather than a CanExecute so the row button can bind IsEnabled directly.) + /// + [RelayCommand] + private async Task RevokeInviteAsync(EmailInvite invite) + { + if (!IsOwner || _target is not { } target || invite.Token is not { Length: > 0 } token) return; + + ErrorMessage = null; + StatusMessage = null; + try + { + await target.RevokeInviteAsync(token); + StatusMessage = $"Invite to {invite.Email} revoked."; + await ReloadInvitesAsync(_loadGeneration); + } + catch (InterlinedApiException ex) + { + ErrorMessage = ex.Message; + } + } + + [RelayCommand] + private void CopyInviteLink(EmailInvite invite) + { + if (_target is not { } target) return; + + var url = invite.Url + ?? (invite.Token is { Length: > 0 } token ? target.InviteUrlFor(token) : null); + if (url is null) return; + + try + { + Clipboard.SetText(url); + StatusMessage = "Invite link copied. It only works for the invited address."; + } + catch (Exception ex) + { + // Another process can hold the clipboard; never take the app down for it. + ErrorMessage = $"Couldn't copy the invite link: {ex.Message}"; + } + } + + [RelayCommand] + private void SelectRole(InviteOptionViewModel option) => SelectOnly(Roles, option); + + [RelayCommand] + private void SelectExpiry(InviteOptionViewModel option) => SelectOnly(Expiries, option); + + private static void SelectOnly( + IEnumerable options, InviteOptionViewModel chosen) + { + foreach (var option in options) + option.IsSelected = ReferenceEquals(option, chosen); + } + + private async Task ReloadInvitesAsync(int generation) + { + if (_target is not { } target || !IsOwner) return; + + try + { + var invites = await target.GetInvitesAsync(); + if (generation != _loadGeneration) return; + + Invites.Clear(); + foreach (var invite in invites) + Invites.Add(invite); + } + catch (InterlinedApiException ex) + { + if (generation == _loadGeneration) ErrorMessage = ex.Message; + } + } + + private void RefreshSubscriberState() + { + // Authoritative per /help/api: customerStatus is "free", "subscriber", + // "subscriber:monthly" or "subscriber:annual", and *any* non-"free" + // value grants subscriber access. A null/absent status is treated as + // free — the server is the real gate either way. + var status = _session.CurrentUser?.CustomerStatus; + IsSubscriber = status is { Length: > 0 } + && !string.Equals(status, "free", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsValidEmail(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + // The server validates syntactically too (400 "A valid email address is + // required"); this just keeps Send Invite disabled until it's plausible. + return MailAddress.TryCreate(value.Trim(), out _); + } +} diff --git a/InterlinedList/Views/InvitePanel.xaml b/InterlinedList/Views/InvitePanel.xaml new file mode 100644 index 0000000..c28c8c1 --- /dev/null +++ b/InterlinedList/Views/InvitePanel.xaml @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + +