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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/InvitePanel.xaml.cs b/InterlinedList/Views/InvitePanel.xaml.cs
new file mode 100644
index 0000000..a7b31cb
--- /dev/null
+++ b/InterlinedList/Views/InvitePanel.xaml.cs
@@ -0,0 +1,67 @@
+using System.Windows;
+using System.Windows.Controls;
+using InterlinedList.Services;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// The shared "Invite people" card. Hosts drive it declaratively — no
+/// code-behind wiring needed in the hosting view:
+///
+///
+/// <local:InvitePanel Kind="List" TargetId="{Binding SelectedList.Id}"/>
+/// <local:InvitePanel Kind="Document" TargetId="{Binding SelectedDocument.Id}"/>
+///
+///
+/// A null/empty (nothing selected, or a list someone
+/// else shared with you) collapses the whole card. Like every other view here
+/// it news up its own ViewModel from rather than
+/// having one injected.
+///
+public partial class InvitePanel : UserControl
+{
+ public static readonly DependencyProperty TargetIdProperty =
+ DependencyProperty.Register(
+ nameof(TargetId), typeof(string), typeof(InvitePanel),
+ new PropertyMetadata(null, OnTargetChanged));
+
+ public static readonly DependencyProperty KindProperty =
+ DependencyProperty.Register(
+ nameof(Kind), typeof(InviteTargetKind), typeof(InvitePanel),
+ new PropertyMetadata(InviteTargetKind.List, OnTargetChanged));
+
+ private readonly InvitePanelViewModel _vm;
+
+ public InvitePanel()
+ {
+ InitializeComponent();
+ _vm = new InvitePanelViewModel(AppServices.Session);
+ DataContext = _vm;
+ }
+
+ /// The id of the list/document whose invites this panel manages.
+ public string? TargetId
+ {
+ get => (string?)GetValue(TargetIdProperty);
+ set => SetValue(TargetIdProperty, value);
+ }
+
+ /// Which family of invite endpoints to drive.
+ public InviteTargetKind Kind
+ {
+ get => (InviteTargetKind)GetValue(KindProperty);
+ set => SetValue(KindProperty, value);
+ }
+
+ private static void OnTargetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((InvitePanel)d).Retarget();
+
+ private void Retarget()
+ {
+ var target = InviteTargets.For(Kind, TargetId, AppServices.Api);
+ // Fire-and-forget: SetTargetAsync owns its own errors and stamps a
+ // generation so a slower earlier load can't paint over a newer one.
+ _ = _vm.SetTargetAsync(target);
+ }
+}
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index eaf3e0f..2042e68 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -573,6 +573,9 @@
+
+
+