Lists: invite people to a list by email (#55) - #168
Merged
Merged
Conversation
Adds the invite-by-email path the web Share window leads with: a specific
person (who may not have an account yet) is granted a role while the list
stays private, unlike a share link (a bearer capability) or a watcher (an
existing account).
Service lives in a new domain partial, Services/InterlinedApiClient.ListInvites.cs,
rather than in .Lists.cs — partial-per-domain is this repo's convention and it
keeps the contended file untouched.
All shapes verified live 2026-09-16 against a throwaway list, then cleaned up:
GET /api/lists/{id}/invites -> 200 {"invites":[{email,role,
expiresAt,accepted,createdAt,token}]}
POST /api/lists/{id}/invites -> 201 {email,role,expiresAt,url}
DELETE /api/lists/{id}/invites/{token} -> 200 {"revoked":true}
Note the asymmetry the models capture: the create response carries `url` but no
`token`, and the listing carries `token` but no `url` — so creating is followed
by a read-after-write to get the token Revoke needs. `role` is accepted and
round-trips (watcher/collaborator/manager) even though the published OpenAPI
body schema omits it; an invalid role is 400, an invalid address is 400, and an
unknown token is 404.
The UI is one reusable control, Views/InvitePanel, driven by IInviteTarget so
issue #56 can host the same card over /api/documents without duplicating it.
Hosting it costs ListsView.xaml exactly one line, because the panel takes its
target declaratively (Kind + TargetId) and owns its own ViewModel.
Product rules encoded rather than left to a server error:
- only the true owner can manage sharing — not even a manager-role
collaborator — so the panel resolves the owner id off the list payload and
gates on it;
- inviting is subscriber-only, so a free account sees the controls locked with
an Upgrade prompt instead of hidden, and the invitee never pays;
- revoking is deliberately NOT subscriber-gated, so Revoke stays live even
while the send form is locked.
Closes #55
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the invite-by-email path that the web Share window now leads with: give a
specific person — who may not have an account yet — a role on a list while the list
stays private. Distinct from the two sharing paths already shipped: a share link is a
bearer capability (whoever holds the token has access) and a watcher must already be an
InterlinedList account.
Branch / file placement
Services/InterlinedApiClient.Lists.csis in unmerged PR #143 andViews/ListsView.xamlis in the #161/#163/#164 stack, so this branches from
mainand puts the service in anew domain partial —
Services/InterlinedApiClient.ListInvites.cs. Partial-per-domainis this repo's stated convention, so that's idiomatic rather than a dodge, and
.Lists.csis not touched at all.
ListsView.xamltakes exactly one functional line (plus a comment):That was possible because the panel takes its target declaratively (
Kind+TargetIddependency properties) and news up its own ViewModel fromAppServices, sothere is no code-behind edit in
ListsView.xaml.csand no change toListsViewModel.cs(which is on the do-not-touch list anyway).Live verification (2026-09-16)
Probed with a bearer sync-token against a throwaway list I created and then deleted —
never the account's own
New list.Error paths, also live:
Two findings worth knowing, both encoded in the models:
urlbut notoken;the listing carries
tokenbut nourl. Revoke is keyed by token, so creating isfollowed by a read-after-write GET.
EmailInvite.Token/Urlare therefore bothnullable, and the row rebuilds the landing URL from the token for "Copy link".
roleworks even though the published OpenAPI body schema omits it (that schemalists only
email+expiresAt). It is accepted and round-trips for all three values,matching
/help/api/sharing. The response envelope is live-verified, soCreateListInviteAsyncdeserializes it — but the panel still re-reads the GET, per thisrepo's read-after-write rule and because it needs the token.
GET /api/lists/{id}was also confirmed to carryuserIdin itsdataenvelope, whichis how ownership is resolved without modifying
ListSummary(aModels/*file I must nottouch, and which doesn't model that field on
main).What I deliberately did not verify
claude-probe@il-probe.invalid—.invalidis an IANA-reserved TLD that can neverresolve, so the server's fire-and-forget invite email cannot reach a person. Each invite
was revoked immediately after its shape was captured.
GET/POST /api/lists/invite/{token}). Claiming is session-cookie-only per the API docs — abearer-token native client structurally can't do it — and it needs a second verified
account. That's the rest of epic Epic: Sharing, invites and collaboration depth #54, not this issue.
notify: falseon the invite create is accepted (201) but notechoed, so I could not confirm it suppresses the email and did not expose a tick
for it. The "Email this person" tick in the web belongs to the named-person
(watcher/collaborator) flow, where
notifyis documented — for an email invite theemail is the delivery mechanism. Wiring
notifyinto the existingAddListWatcherAsyncwould mean editing the contended.Lists.cs, so it's left for afollow-up.
Cleanup confirmed
The throwaway list
ZZ claude-probe invites 55was deleted, and a closingGET /api/listsreturns{"lists":[{"title":"New list"}],"pagination":{"total":1}}— theaccount's own content is untouched.
Product behaviour encoded (from
/help/documents+/help/api/sharing)Three rules are enforced in the UI rather than left to a server error:
(
manager) collaborator cannot manage sharing. The panel resolves the owner id fromthe resource payload and gates on it; a non-owner sees a plain notice, and the server's
own answer for a non-owner is
404(existence is never leaked), which the panelswallows rather than surfacing as an error.
and disabled with an amber Upgrade prompt, matching the web. Gate is
customerStatus != "free"— authoritative per/help/api, where the values arefree,subscriber,subscriber:monthly,subscriber:annualand any non-freevalue grantsaccess. The invitee never pays.
granted, so Revoke stays live while the send form is locked.
Roles use the product's labels over the wire values: Read-only (
watcher) / Edit(
collaborator) / Admin (manager), each with the one-line "can do" description from thehelp centre. Expiry offers Never / 7 days / 30 days, and the pending row shows
address · role · Pending-or-Accepted · expiry, exactly as the web's "Pending invites" does.
What still needs wiring
InviteTargets.Forhas theone-line seam waiting for it.
Notes for review
themes), so both pickers are segmented buttons driven by an option's
IsSelectedratherthan by RadioButton grouping — grouping is visual-tree scoped and would misbehave with
the Lists and Documents panels both alive in the MainWindow view cache.
PrimaryBrush,AmberBrushfor the locked state,Surface*,Border*,Text*), 4px card corners / 3px rows, 4pt spacing. The one literal is theerror red
#FFE81123, copied from the existing error banners inListsViewforconsistency.
-c Debugand-c Release(-r win-x64, 0 warnings).can't paint over a newer one.
Closes #55
🤖 Generated with Claude Code